Merge pull request #3885 from vitorpamplona/claude/concord-soft-ban-vulnerability-rbjjet

fix(concord): close the soft-ban authority holes (audit A1–A4, B1, B2, B4)
This commit is contained in:
Vitor Pamplona
2026-08-09 15:14:29 -04:00
committed by GitHub
19 changed files with 2022 additions and 57 deletions
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven
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.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
@@ -76,6 +77,15 @@ private const val CONCORD_ADMIN_ROLE = "Admin"
*/
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
/**
* How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`.
*
* 120 blobs ride in each kind-3303 chunk, so this is ~42 published events and ~5k NIP-44
* encryptions at the ceiling — heavy but survivable on a phone, and far above any real community.
* Raising it raises the cost of the attack it exists to bound, not the safety.
*/
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
/**
* Concord (encrypted communities) orchestration for an [Account]: join/create/
* invite flows, channel messages/reactions/edits/typing, roles and moderation,
@@ -171,6 +181,21 @@ class AccountConcordActions(
val entry =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId } ?: return null
// CREATE_INVITE, and not while banned. This used to check only that we held the community,
// which made minting the one moderation-free action in the app: a member the owner had just
// banned could tap the invite button and hand out a working link to the community they were
// removed from, and every account they invited arrived as a fresh un-banned npub.
//
// Note the bit is not otherwise enforced anywhere. The fold gates the INVITE_* Control
// entities on CREATE_INVITE, but a link's bundle is a standalone kind-33301 published
// OUTSIDE the Control Plane, so no fold ever sees it. This check is the only one there is.
// The owner is proven by the community id (CORD-02), so they are read off the entry and can
// mint before the session exists — the session is built asynchronously off the joined list,
// and requiring it here would have made the owner's own invite button fail on a cold start.
// Everyone else needs the folded roster, so no session means no invite.
val session = account.concordSessions.sessionFor(communityId)
val amOwner = entry.owner.equals(account.signer.pubKey, ignoreCase = true)
if (!amOwner && (session == null || !isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE))) return null
val invite =
ConcordActions.inviteFor(
communityIdHex = entry.id,
@@ -430,7 +455,17 @@ class AccountConcordActions(
channelIdHex: String,
) {
if (!account.isWriteable()) return
val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return
val session = account.concordSessions.sessionFor(communityId) ?: return
// A ban hides every message we send, so continuing to announce that we are typing them is
// both noise and a contradiction of what the ban told the room. Filtered on the receive side
// too (ConcordCommunitySession.ingestTyping) — a malicious client would keep sending.
if (session.state.value
?.authority
?.isBanned(account.signer.pubKey) == true
) {
return
}
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now())
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
@@ -487,6 +522,49 @@ class AccountConcordActions(
return cp
}
/**
* Whether this account may take the action guarded by [bit] in [session] — and, when [target] is
* given, take it *against that member* (CORD-04 §3's rank rule, "equal cannot act on equal").
*
* Every moderation verb below funnels through this. It used to live only in the composables that
* drew the buttons, which failed three ways: the screens tested `effectivePermissions`, which
* ignores the banlist, so a banned staffer still saw the controls; a verb reached from anywhere
* else (desktop, `amy`, a new screen) inherited no check at all; and holding `control_root` —
* a spam gate, never authority (CORD-02 §5) — was the only thing actually being enforced.
*
* Fails **closed**, with one deliberate exception: the owner is read from [ConcordCommunityListEntry]
* rather than from the fold, because the community id proves them (CORD-02) and they must stay able
* to moderate before their Control Plane has finished folding — or through a fold a rogue has
* damaged. Everyone else needs a resolved roster, so an unfolded community grants nobody else
* anything.
*/
private fun isAuthorizedFor(
session: ConcordCommunitySession,
bit: Int,
target: HexKey? = null,
): Boolean {
val me = account.signer.pubKey
if (session.entry.owner.equals(me, ignoreCase = true)) return true
val authority = session.state.value?.authority ?: return false
// hasPermission, never effectivePermissions: the latter reads the roles alone and would let a
// banned staffer keep acting for as long as they hold the key.
val allowed = if (target == null) authority.hasPermission(me, bit) else authority.canActOn(me, target, bit)
if (!allowed) {
Log.w("Concord") { "Refusing a Concord action in ${session.entry.id}: not authorized for bit $bit${target?.let { " on $it" } ?: ""} (CORD-04 §3)" }
}
return allowed
}
/** [controlKeysForWrite] gated by [isAuthorizedFor] — the standing check and the key check together. */
private fun controlKeysForAction(
session: ConcordCommunitySession,
bit: Int,
target: HexKey? = null,
): ControlPlaneKeys? {
if (!isAuthorizedFor(session, bit, target)) return null
return controlKeysForWrite(session)
}
/** Grant [member] exactly [roleIds] (empty list revokes their roles). */
suspend fun grantConcordRole(
communityId: String,
@@ -495,7 +573,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
// 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.
@@ -567,7 +645,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
val existing =
session.state.value
@@ -609,7 +687,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, grantWrap)
return true
@@ -657,7 +735,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
@@ -670,7 +748,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
val wrap = ConcordModeration.unban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
@@ -701,10 +779,22 @@ class AccountConcordActions(
val session = account.concordSessions.sessionFor(communityId) ?: return false
val state = session.state.value ?: return false
val authority = state.authority
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.BAN)
// hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol
// and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the
// shipping app. Honest receivers refuse such a rotation (drainConcordRekeys checks the same
// ban-aware predicate), but that is a race against banlist propagation, not a check.
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.hasPermission(account.signer.pubKey, ConcordPermissions.BAN)
if (!iCanBan) return false
val removedLower = removed.mapTo(HashSet()) { it.lowercase() }
if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false
// Removal is the hardest form of a ban, so it takes the same rank rule (CORD-04 §3): an admin
// cannot Refound a peer admin out of the community any more than they could ban one. The owner
// short-circuits, as everywhere else, because canActOn starts at hasPermission.
if (!authority.isOwner(account.signer.pubKey) &&
removedLower.any { !authority.canActOn(account.signer.pubKey, it, ConcordPermissions.BAN) }
) {
return false
}
// A Refounding writes the current plane (the pre-rotation bans) and the new one (the
// compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A
// rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery.
@@ -735,7 +825,7 @@ class AccountConcordActions(
.apply {
removeAll(removedLower)
removeAll(authority.bannedMembers())
}.toList()
}.let { candidates -> boundRecipients(candidates, authority) }
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
val entry = session.entry
@@ -762,6 +852,7 @@ class AccountConcordActions(
recipientsXOnly = recipients,
staffXOnly = staff,
createdAt = TimeUtils.now(),
ownerPubKey = entry.owner,
)
// 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs
@@ -778,6 +869,49 @@ class AccountConcordActions(
return true
}
/**
* Caps the Refounding recipient set, keeping the members whose standing we can actually vouch
* for when there are too many.
*
* `allMembers()` is the Guestbook `observedAuthors` the roster, and the first two are
* unbounded and attacker-writable: a Guestbook Join is self-signed by any key at all, and every
* author we decrypt is folded in by design (CORD-02 §5, "observably present"). So each throwaway
* npub someone posts from, or simply announces, becomes one more mandatory blob in the next
* Refounding — meaning the attack inflates the cost of its own remedy, and the remedy is the only
* hard removal Concord has. See B4 in `docs/concord-soft-ban-audit.md`.
*
* The roster and the owner are kept unconditionally: they are owner-rooted, so they cannot be
* padded from outside. The remainder fills the budget, and anything dropped is **logged rather
* than silently truncated** — a dropped member is stranded on the dead epoch and their only way
* back is a recovery path that needs to know it happened.
*/
private fun boundRecipients(
candidates: Set<HexKey>,
authority: AuthorityResolver,
): List<HexKey> {
if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList()
// The roster goes in whole even if it alone exceeds the budget: it is owner-rooted, so it
// cannot be padded from outside, and dropping an admin to make room for a stranger inverts
// the point of the cap.
val vouched = authority.roleHolders() + authority.staffMembers()
val kept = LinkedHashSet<HexKey>()
candidates.filterTo(kept) { it in vouched }
for (candidate in candidates) {
if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break
kept.add(candidate)
}
val dropped = candidates.size - kept.size
if (dropped > 0) {
Log.w("Concord") {
"Refounding recipient set trimmed to ${kept.size} of ${candidates.size} " +
"(budget $MAX_REFOUNDING_RECIPIENTS, roster kept whole): $dropped member(s) will be " +
"stranded on the prior epoch"
}
}
return kept.toList()
}
// Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered
// in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not
// adopted — and re-published — twice on successive revision ticks.
@@ -986,7 +1120,29 @@ class AccountConcordActions(
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
// A removed member holds the link's unlock token forever, so without this the sweep
// walks them straight back into the epoch they were rotated out of — see A2 in
// docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last
// one whose Control Plane we can still fold.
//
// Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not
// exist yet or whose first fold has not landed, and this sweep runs on the revision tick
// — so a banned member's own client would have hit that window on cold start and
// recovered itself, which is precisely the bypass this gate exists to stop. No verdict
// means no recovery; the next sweep retries once the roster is known.
val authority =
account.concordSessions
.sessionFor(entry.id)
?.state
?.value
?.authority
if (authority == null) {
Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" }
lastConcordRecoveryCheck.remove(entry.id)
continue
}
val bannedHere = authority.isBanned(account.signer.pubKey)
val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged))
@@ -1009,7 +1165,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
@@ -1027,7 +1183,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
val channelId = RandomInstance.bytes(32)
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
@@ -1043,7 +1199,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
// Carry the standing definition forward and change only the name. A ChannelEntity built from
// scratch defaults `private` and `voice` to false, so renaming a private channel used to
// publish an edition declaring it PUBLIC — and a voice channel became a text channel.
@@ -1066,7 +1222,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
// Same as rename: preserve the standing flags so a tombstone does not also silently
// reclassify the channel it retires.
val standing =
@@ -170,10 +170,14 @@ fun ConcordChannelListScreen(
// Rank alone isn't enough on a split epoch: publishing any Control edition also takes the
// control_root (CORD-02 §2), which a freshly promoted staffer may not hold yet (CORD-04 §3),
// so the affordance waits for the key too.
// hasPermission, never effectivePermissions: the latter reads the roles alone, so a banned
// moderator kept seeing every control here. The editions they authored were dropped by everyone's
// fold, which made these buttons silently no-op — worse than absent, and the same trap this file
// already avoids for the Roles… menu.
val canManageChannels =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS)
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_CHANNELS)
} == true &&
session?.controlPlaneKeys()?.canWrite == true
@@ -242,10 +246,19 @@ fun ConcordChannelListScreen(
val canEdit =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA)
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_METADATA)
} == true &&
session?.controlPlaneKeys()?.canWrite == true
// Minting an invite hands out a working key to the community, so it takes
// CREATE_INVITE like any other privileged action. This button used to be the one
// control on the screen with no gate at all.
val canInvite =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.hasPermission(account.signer.pubKey, ConcordPermissions.CREATE_INVITE)
} == true
IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) {
SymbolIcon(symbol = MaterialSymbols.Group, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_members_title))
}
@@ -254,22 +267,24 @@ fun ConcordChannelListScreen(
SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title))
}
}
IconButton(
enabled = !minting,
onClick = {
minting = true
scope.launch {
try {
inviteLink = account.concord.mintConcordInvite(communityId)
} finally {
// Always clear the flag — a thrown mint would otherwise leave the
// button disabled until the screen is recreated.
minting = false
if (canInvite) {
IconButton(
enabled = !minting,
onClick = {
minting = true
scope.launch {
try {
inviteLink = account.concord.mintConcordInvite(communityId)
} finally {
// Always clear the flag — a thrown mint would otherwise leave the
// button disabled until the screen is recreated.
minting = false
}
}
}
},
) {
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
},
) {
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
}
}
// Overflow, mirroring the NIP-29 relay-group top bar: destructive membership
@@ -133,7 +133,10 @@ fun ConcordMembersScreen(
}
val iAmOwner = state?.authority?.isOwner(myPubKey) == true
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true
// hasPermission, never effectivePermissions: a banned BAN-holder used to keep the whole Ban /
// Remove menu. It only stayed harmless because `canBanTarget` below routes through canActOn,
// which IS ban-aware — a thin margin for the escalation in docs/concord-soft-ban-audit.md.
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.BAN) } == true
val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true
// The roles this viewer may actually hand out. The fold drops a grant whose granter does
@@ -454,7 +454,8 @@ object ConcordActions {
fun recoverStranded(
entry: ConcordCommunityListEntry,
bundle: CommunityInvite,
): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle)
bannedAtCurrentEpoch: Boolean,
): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle, bannedAtCurrentEpoch)
/** Decrypts + validates a fetched bundle event with the link token; null if invalid. */
fun openBundle(
@@ -544,6 +545,7 @@ object ConcordActions {
recipientsXOnly: List<HexKey>,
staffXOnly: Set<HexKey>,
createdAt: Long,
ownerPubKey: HexKey,
): RefoundingBuild =
ConcordRefounding.build(
rotatorSigner = rotatorSigner,
@@ -557,6 +559,7 @@ object ConcordActions {
recipientsXOnly = recipientsXOnly,
staffXOnly = staffXOnly,
createdAt = createdAt,
ownerPubKey = ownerPubKey,
)
/**
@@ -486,6 +486,9 @@ class ConcordCommunitySession(
if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, epoch)) return
val who = rumor.pubKey.lowercase()
if (who == myPubKey.lowercase()) return // never show my own typing back to me
// A banned member's messages are dropped everywhere, so their typing heartbeat must be too —
// otherwise they sit in the "… is typing" row forever in a channel they cannot be heard in.
if (_state.value?.authority?.isBanned(who) == true) return
val now = TimeUtils.now()
// Update the map and publish inside the lock so a concurrent heartbeat on another
// channel can't publish an older snapshot last and drop this channel's typers.
@@ -123,6 +123,7 @@ class ConcordActionsTest {
// Only the owner is staff, so only the owner's blob carries the secret.
staffXOnly = setOf(owner.pubKey),
createdAt = 5L,
ownerPubKey = owner.pubKey,
)
val baseRekey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch)
@@ -78,7 +78,7 @@ class ConcordRollbackFloorTest {
// new epoch's plane is split and addressed by the derived signer, not the root.
val newControlRoot = ByteArray(32) { 0x44 }
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl)
val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey)
val entry =
ConcordCommunityListEntry(
@@ -145,7 +145,7 @@ class ConcordRollbackFloorTest {
val newControlRoot = ByteArray(32) { 0x44 }
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
// Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives.
val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl)
val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl, community.ownerPubKey)
val entry =
ConcordCommunityListEntry(
@@ -186,7 +186,7 @@ class ConcordRollbackFloorTest {
// new epoch's plane is split and addressed by the derived signer, not the root.
val newControlRoot = ByteArray(32) { 0x44 }
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl)
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey)
val entry =
ConcordCommunityListEntry(
+57 -1
View File
@@ -1,7 +1,8 @@
# Concord: the Banlist is not rank-gated in any implementation (CORD-04 conformance)
**Status:** conformance bug. Reproduced in Amethyst and **fixed there** (see §6); present by
inspection in Armada.
inspection in Armada. Finding #3, left open in §4 as a fixpoint-ordering question, is now also
implemented in Amethyst — see the 2026-08-09 update before §Rollout status.
**Severity:** privilege escalation. Any `BAN` holder can neutralise every authority above them,
including the owner.
**Reported by:** Amethyst (MIT), 2026-07-20. Findings verified by unit test; see "Evidence" below.
@@ -190,6 +191,61 @@ We'd also suggest **§4 restating the rank half inline**, the way §2 does for G
does for Kicks. Both independent implementations read §4 in isolation and both got it wrong the same
way; that is strong evidence the section is the problem, not the readers.
### Update, 2026-08-09: we have now implemented #3
Amethyst now answers the ordering question rather than leaving it open, because #3 turned out to be
the doorway to a full community takeover and not merely an inconsistency — a banned staffer who kept
`control_root` kept the entire roster, and could mint a fresh un-banned npub that passed every
ban-aware gate. The write-up is `docs/concord-soft-ban-audit.md` (B2).
The rule we shipped: **authority only ever shrinks, over two passes.** Pass A resolves exactly as
before and yields a candidate banlist; pass B re-resolves with every author on that list treated as
holding no authority at all, for roles, grants and the Banlist alike. Two passes, always, so it
terminates by construction. It cannot oscillate on mutual bans either, because the rank rule makes
them unreachable: only a member who strictly outranks you may ban you, and you cannot outrank them
back.
Note what it costs, because it is not obvious and you would hit it too: this **cascades**. Every
edition a banned member ever authored is dropped, grants included, so banning an admin also demotes
everyone that admin promoted. We think that is the literal reading of §4 and it is what kills the
sockpuppet — but a legitimate promotion by a later-banned admin vanishes with it, and the owner has
to re-issue it. If you read §4 as scoping only to editions authored *after* the ban, say so; that is
implementable too, but it needs the spec to define an ordering between an edition and a Banlist
entry, which today it does not.
We checked your implementation before writing this, and you got there first: `foldControlState`
already runs the same two-pass, with the same §4 justification in the comment. So this is us catching
up, not diverging — with one narrower difference. You keep **pass 1's** Banlist as the final word;
we recompute it in pass 2. So a banned admin's mass-ban of everyone beneath them still stands for you
and is dropped by us. Your stated reason is to stop the anti-roster erasing itself; ours is that an
edition should not outlive its author's removal, and the self-erasure case is unreachable under the
rank rule anyway, since only a member who strictly outranks you can ban you. We would rather converge
than be right — tell us which way and we will move.
Two more things that fell out of reading `src/concord-v2/` side by side, both worth their own look:
- **`bootstrapHead` has no bound** (`lib/version.ts`), and `headCandidates` uses it for the
compaction arm while `pickHead` then raises the stored floor to whatever won. One authorized
edition at `version = 2^63 - 1` therefore becomes an entity's permanent head: the floor rises to
match, nothing honest can exceed it, and even a Refounding that drops the edition falls back to the
remembered head — which is that edition. It needs no ban and no sockpuppet, just one ordinary
permission bit. This is the second bug both implementations share by reading the same section the
same way; ours is described in `docs/concord-soft-ban-audit.md` (B1), and we bounded the jump the
arm will follow.
- **Your Banlist takes only the gated head's content**, with no §4 re-heal union. We union in every
authorized non-ancestor edition, which is what defeats an attempt to launder a ban away by forking
the list at genesis. So we honor concurrent bans you drop. Which is normative?
A chain-local rule is not enough, and this is the trap worth flagging: "the author must not be banned
by the state their edition chains from" is bypassed by forking the Banlist at genesis, where no
parent ever mentions the ban and §4's re-heal union carries it in anyway. The rule has to bind the
union, which is why ours is a whole-pass mask rather than a per-edition check.
This widens the divergence in §6: we now drop editions you honor in any community where a privileged
member was banned, not only where a signer failed to outrank their target.
---
Separately, please rule on **#3**: whether a banned npub's Banlist edition is honored. Our reading of
§4 ("drops every event from a banned npub — message, reaction, edit, or authority action") is that it
must not be, but the fixpoint ordering needs to be stated for that to be implementable consistently.
+583
View File
@@ -0,0 +1,583 @@
# Concord: soft-ban and Control Plane audit
**Scope:** what a removed member — or a moderator who turns — can still do to a Concord community.
**Date:** 2026-08-09. **Status:** A1A4, B1, B2 and B4 are **fixed** on this branch; the rest are
accepted, deferred to the spec, or belong to other people's relays. Each section carries its own
status line.
**Companion:** `docs/concord-banlist-rank-conformance.md` (the rank half of CORD-04 §4, already
reported to Armada and fixed here).
Each finding says how it was established. **Verified** means a test in this repo reproduces it;
**Read** means it follows from the code but no test was written. Every "Verified" line names the test.
---
## How to read this list
Findings are split by **what the attacker needs**, because that decides who owns the fix and how
urgent it is:
- **[Part A — reachable from stock Amethyst](#part-a).** A banned user opens the shipping app and
taps a button, or our own client does it for them on a timer. These are straightforwardly *our
bugs*, they need no attacker sophistication at all, and every one of them is fixable in this repo
without touching the protocol or coordinating with anyone.
- **[Part B — requires a malicious client](#part-b).** The attacker writes their own events, so no
client-side rule binds them. We cannot stop them from *authoring* anything; we can only refuse to
*honor* it. Fixes live in the fold, the store, or the spec.
- **[Part C — interop and not-yet-shipped surfaces](#part-c).**
The distinction is not academic. Part A is where the realistic attacker is: an irritated user who
just got banned has the app already installed and is not going to write a Nostr client. Part B is
where the *damage ceiling* is. Fix Part A first because it is cheap and it is what will actually
happen; fix Part B because it is what ends communities.
Two structural causes account for most of both halves:
- **Authority is checked in several places that disagree.** `ConcordCommunityState.fold` gates
METADATA/CHANNEL/INVITE through the ban-aware `authority.hasPermission`. `AuthorityResolver`
gates ROLE/GRANT/BANLIST internally through `holdsManageRoles` / `bitsOf` /
`effectivePermissionsOf`, which are ban-blind. The **UI** gates through `effectivePermissions`,
also ban-blind. The **action layer** mostly does not gate at all. Same question, four answers.
- **A ban removes standing, never keys.** `community_root`, channel keys, `control_root` if staff,
and live invite links all survive it. Only a CORD-06 Refounding rotates those — which is why
anything that makes Refounding expensive (B4) or reversible (A2) is worth more to an attacker
than it first looks.
---
## Summary
### <a name="part-a"></a>Part A — reachable from stock Amethyst (our bugs)
| # | Finding | Severity | Status |
|---|---------|----------|--------|
| [A1](#a1) | Any member — banned included — mints a working invite in one tap | **Critical** | **Fixed** |
| [A2](#a2) | Stranded recovery runs on a timer and never checks the banlist | **Critical** | **Fixed** (security half; liveness half open) |
| [A3](#a3) | The action layer has no permission checks; the UI's are ban-blind | High | **Fixed** |
| [A4](#a4) | A banned member keeps broadcasting "typing", and we keep showing it | Low | **Fixed** |
| [A5](#a5) | A banned member's own client keeps reading and rendering everything | Medium | Inherent — product decision |
### <a name="part-b"></a>Part B — requires a malicious client
| # | Finding | Severity | Needs a ban? | Status |
|---|---------|----------|--------------|--------|
| [B1](#b1) | One edition at `version = Long.MAX_VALUE` pins an entity forever | **Critical** | No — any bit-holder | **Fixed** |
| [B2](#b2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | **Fixed** (matches Armada) |
| [B3](#b3) | A rogue rotator compacts the banlist away | High | Via B2 | **Mitigated** by B2 |
| [B4](#b4) | The Refounding recipient set is attacker-inflatable | High | No | **Fixed** (bounded) |
| [B5](#b5) | The ban is per-pubkey; the channel key is not revoked | High | Yes | Inherent — Refounding is the answer |
| [B6](#b6) | Channel history is deletable on a naive third-party relay | High | Yes | Correct here; external relays at risk |
| [B7](#b7) | The base-rekey plane is writable by every member | Low | Yes | Accepted |
### <a name="part-c"></a>Part C — interop and not-yet-shipped
| # | Finding | Severity | Status |
|---|---------|----------|--------|
| [C1](#c1) | Banlist rank rule diverges from Armada | Medium | Reported; B2 widens the divergence |
| [C2](#c2) | CORD-07 voice rooms are key-gated, not roster-gated | Design | Note for whoever ships voice |
---
# Part A — reachable from stock Amethyst
No custom tooling. A banned user with the shipping app, or our own background sweep.
## <a name="a1"></a>A1 — Any member, banned included, mints a working invite in one tap
**Status: fixed.** `mintConcordInvite` and its button now require `CREATE_INVITE` (or ownership).
**Critical. The single most likely thing an irritated banned user actually does.**
*Read:* `AccountConcordActions.mintConcordInvite`, `ConcordChannelListScreen` (the `PersonAdd`
`IconButton`).
`mintConcordInvite` checks exactly two things: that the account is writeable, and that we have the
community in our joined list. **No `CREATE_INVITE` check. No banlist check.** And unlike the Edit
and channel-management buttons beside it, the invite `IconButton` is rendered with no `canEdit`
guard at all — it is always there, for everyone.
So the flow is: get banned, stay in the app, tap the person-add icon, share the link. The minted
bundle carries the community root we still hold, so anyone who opens it joins for real. Every
invited account is a fresh unbanned npub that moderators then have to ban one at a time.
Two aggravating details. The mint publishes a **fresh link signer per invite**, so it is a brand-new
coordinate — revoking the links the banned member was given does not touch the ones they mint.
And `CREATE_INVITE` is a real permission bit that the fold enforces on `INVITE_*` Control entities,
but the actual invite mechanism is a standalone kind-33301 addressable event published *outside* the
Control Plane, so that gate never applies to it. The permission is, in practice, unenforced.
**Fix.** Gate `mintConcordInvite` on `hasPermission(me, CREATE_INVITE) || isOwner(me)`, and gate the
button on the same. This is contained, uncontroversial, and closes the realistic attack. Do it first.
## <a name="a2"></a>A2 — Stranded recovery runs on a timer and never checks the banlist
**Status: security half fixed; liveness half open — and the fork is now resolved.** `isStranded` /
`mergeForward` take `bannedAtCurrentEpoch` as a *required* argument, so a removed member is no longer
walked back in. The open question was whether anything re-mints at a stable coordinate. **Armada
does** — `useLinkRefreshWatch2` re-posts every bundle on each epoch change — so in any cross-client
community this was a *live* removal bypass, not a hypothetical, and the fix was load-bearing. The
liveness half stands: Amethyst re-mints nothing, so legitimate recovery never fires for an
Amethyst-only community. See [the Armada comparison](#armada) for the two ways out.
**Critical, and it forks.** *Read:* `ConcordStrandedRecovery`,
`AccountConcordActions.recoverStrandedConcordCommunities`, `AccountConcordActions.mintConcordInvite`.
This is in Part A because **our own client performs it, unprompted**: the recovery sweep runs on the
revision tick for every joined community holding an `inviteRef`, every 15 minutes. The banned user
does nothing but leave the app installed.
`ConcordStrandedRecovery.isStranded` / `mergeForward` take only `(entry, bundle)` — no banlist
check, no check that we were legitimately re-keyed. The whole test is "the bundle at my stored
`inviteRef` sits at a higher epoch than I do", and the unlock token lives in the link fragment an
ex-member keeps forever. So whether a removed member walks back in depends *only* on whether
anything re-mints at that coordinate:
- **If nothing re-mints** — today, since Amethyst mints a fresh link signer per invite and the
Refounding neither re-mints nor revokes — stranded recovery never fires for anyone. It is dead
code, and the cure `drainConcordRekeys`' own KDoc points to for "a BAN-holder can evict anyone
(the owner included) by omission" does not exist. An owner evicted by a rogue admin has no way back.
- **If anything re-mints at a stable coordinate** — which is what CORD-05's design describes, so
plausibly Armada in a cross-client community — every removed member auto-recovers the new root and
re-announces a Guestbook join, looking current again. **The only hard removal is silently undone.**
Note also that `refoundConcordCommunity` never revokes the links the removed member created or
joined through, though `ControlEntityKind.INVITE_REVOKED` exists and `classifyInvite` honors it.
**Fix.** Decide the intended semantics first — this needs a spec answer. Then gate `mergeForward` on
not being banned in the epoch we merge *from*, have the Refounding revoke the removed members'
links, and either implement re-minting so legitimate recovery works, or drop the mechanism and give
evicted owners another route.
## <a name="a3"></a>A3 — The action layer has no permission checks; the UI's are ban-blind
**Status: fixed.** Authority now lives in `AccountConcordActions.isAuthorizedFor`, which every
moderation verb funnels through, and every authorization test uses the ban-aware `hasPermission`.
**High (defense in depth).** *Read:* `AccountConcordActions` (`banConcordMember`,
`unbanConcordMember`, `editConcordMetadata`, `deleteConcordChannel`, `refoundConcordCommunity`),
`ConcordMembersScreen`, `ConcordChannelListScreen`.
Every moderation verb checks `isWriteable()` and the Control write key, and **nothing else** — no
permission bit, no banlist. Authority lives entirely in the composable that draws the button. Two
consequences:
1. **The UI's own gates are ban-blind.** `iCanBan`, `canEdit` (metadata) and `canManageChannels` all
use `effectivePermissions`, which ignores the banlist. A banned admin still sees the Edit and
channel-management controls. Those particular editions are dropped by every client's fold
(METADATA/CHANNEL are `hasPermission`-gated), so the result is a **silently no-op control**
which this codebase elsewhere explicitly calls out as worse than no control at all.
2. **Ban/Remove survive only because of a second, unrelated gate.** `canBan` is
`viewerCanBan && canBanTarget`, and `canBanTarget` routes through `canActOn`, which *is*
ban-aware. Remove the second condition and a banned admin gets a working Ban button. That is a
thin margin for a Critical-severity outcome (B2).
`refoundConcordCommunity` is the sharpest instance: its own guard is
`isOwner || effectivePermissions(me).has(BAN)` — deliberately ban-blind — so a banned BAN-holder can
launch a full community Refounding from the shipping app. Honest receivers refuse it
(`drainConcordRekeys` checks the ban-aware `hasPermission`), so the blast radius today is noise plus
self-stranding — but it is a race against banlist propagation, and a fresh joiner who has not folded
the ban yet has no reason to refuse.
**Fix.** Move the authority check into the action layer where it cannot be bypassed by a new caller
(desktop, CLI, a future screen), and switch every `effectivePermissions` used as an authorization
test to `hasPermission`. Keep `effectivePermissions` only where the question really is "what do
their roles say", independent of standing.
## <a name="a4"></a>A4 — A banned member keeps broadcasting "typing", and we keep showing it
**Status: fixed on both ends.**
**Low, both halves ours.** *Read:* `AccountConcordActions.sendConcordTyping`,
`ConcordCommunitySession.ingestTyping`.
The send side checks `isWriteable()` and nothing else, so a banned member's stock app keeps emitting
kind-23311 heartbeats. The receive side checks that the rumor is a typing heartbeat, is bound to the
channel/epoch, and is not our own — and nothing else. So a banned member sits in the "… is typing"
row indefinitely, in a channel where every message they send is hidden. Cheap to fix on both ends,
and it directly contradicts what a ban promises the user.
## <a name="a5"></a>A5 — A banned member's own client keeps reading and rendering everything
**Status: inherent; no code change.** The cryptography cannot be fixed without a Refounding, so what
is left is a product decision about how "Ban" and "Remove from community" are presented. Left for a
design pass rather than guessed at here.
**Medium, partly inherent.** *Read:* CORD-02/05, `ConcordCommunitySession`.
Until a Refounding, a ban stops honest clients from *showing* the banned member's posts; it does not
stop delivering the community's posts *to* them. Their stock app keeps subscribing, decrypting and
rendering the whole community in real time. They also keep any invite links they hold (and can mint
more — A1).
The cryptography here is inherent to a soft ban, but the **product** side is ours: "Ban" and "Remove
from community" are very different promises and the UI presents them as neighbours in one menu.
Worth making the difference explicit at the point of choice, and worth defaulting destructive
moderation to the Refounding path.
---
# Part B — requires a malicious client
The attacker writes their own events, so nothing client-side binds them. We can only refuse to honor
what they publish.
## <a name="b1"></a>B1 — One edition at `Long.MAX_VALUE` pins an entity forever
**Status: fixed.** The compaction arm tries the floor-anchored chain first and bounds the bootstrap
jump at `EditionFold.MAX_COMPACTION_VERSION_JUMP`; `compactControlPlane` picks the chain head rather
than raw max version. The three reproductions now assert the fixed behaviour, and
`aGenuineCompactionJumpIsStillFollowed` pins the CORD-06 §3 tolerance the bound must not break.
**Critical. Does not require a banned user, a sockpuppet, or the owner's absence. Unrecoverable.**
*Verified:* `quartz/…/cord04Roles/ControlPlaneVersionExhaustionTest.kt` (3 tests).
Any current holder of an entity's permission bit publishes one edition at `version =
Long.MAX_VALUE`. For every client that holds an `EntityFloor` for that entity, that edition becomes
the permanent head:
1. it wins, so the entity shows the attacker's content;
2. `authorizedHeads` raises the entity's floor to `Long.MAX_VALUE`;
3. no honest edition can ever exceed that floor, so the entity can never be repaired;
4. a Refounding that drops the poison does not help — nothing is offered at or above the floor, the
fold reports a gap, and falls back to `EntityFloor.known`, which *is* the poison.
The chain walk is not the weakness (it advances only to `head.version + 1` citing the head's hash,
so a fresh joiner is unaffected). The weakness is the **compaction arm** of `EditionFold.foldEntity`:
once a floor exists and the entity is in the epoch snapshot — which `fold` always builds from the
editions handed to it — the head comes from `bootstrapHead`, i.e. *highest version at or above the
floor*, with no `prev`, no hash, no contiguity. Version becomes the whole contest.
Concretely: a moderator with `MANAGE_CHANNELS` deletes `#general` permanently for everyone; one
with `MANAGE_METADATA` renames the community permanently. Demoting or banning them afterwards
changes nothing — the damage is in every client's floor. `ConcordRefounding.compactControlPlane`
also selects the head per entity by raw highest version, ungated, so an honest rotator carries the
poison into every future epoch, where fresh joiners then anchor on it as their baseline.
The banlist survives, by accident: `AuthorityResolver` folds it on its own floor-less chain walk and
re-heals the union across authorized editions, so an honest ban still lands. That accident is the
only thing separating this from a permanently unmoderatable community, and it is now pinned by
`aPoisonedBanlistStillAcceptsTheOwnersBan`.
**Fix direction.** The compaction arm needs a bound, since it is the arm that trades contiguity for
cross-epoch tolerance. Options, roughly in order of preference:
- Cap the version delta the arm will accept in one step (a compacted head is legitimately ahead of
the floor, but by a chain's worth, not by 2^63). Anything above the cap is a gap, not a head.
- Make `bootstrapHead` prefer the highest version *reachable by a chain* among the offered editions,
falling back to raw version only when no chain connects.
- Have `compactControlPlane` select the authority-gated fold head rather than raw max version, so a
poison is at least not propagated by honest rotators.
The first is the smallest change and closes the unrecoverability; the third should happen regardless.
## <a name="b2"></a>B2 — A banned staffer keeps Role, Grant and Banlist authority
**Status: fixed. Not consensus-affecting after all** — see [Armada comparison](#armada). Armada
already implements the same two-pass, so this brings us *into* line rather than out of it. One
narrower divergence remains, described there. `AuthorityResolver.resolve` is now a bounded two-pass
where authority only shrinks. Note the deliberate cascade it brings: every edition a banned member
ever authored is dropped, so banning an admin also demotes everyone that admin promoted. That is the
literal reading of CORD-04 §4 and it is what kills the sockpuppet, but a legitimate promotion by a
later-banned admin vanishes with it and has to be re-issued.
**Critical.** *Verified:* `quartz/…/cord04Roles/BannedStaffEscalationTest.kt` (13 tests).
`hasPermission` is ban-aware; the resolver's internal gates are not, and structurally cannot be as
written — the roles/grants fixpoint settles before `banned` is computed. So a banned member who
still holds `control_root` keeps the roster. In the reproduction they:
- ban every member they outrank, directly, with no puppet;
- revoke the surviving moderators' grants and retire the roles beneath them;
- **mint a fresh, unbanned npub** at the next position down, which then passes every ban-aware gate:
deletes every channel, rewrites the metadata, bans the rest of the community, creates invites;
- and, because `drainConcordRekeys` authorizes a rotator by `hasPermission(rotator, BAN)`, that
puppet can publish a Refounding omitting the owner — every honest client follows it and the owner
is stranded on a dead root.
Self-unban is *not* reachable and neither is a puppet-unban: the delta rule gates removals and
strict outranking means nobody outranks themselves, while no edition may claim a position at or
above its signer, so the delegation chain only descends. Two hand-crafted attempts that avoid
removal entirely — forking the banlist at genesis, and building a private chain — are also refused,
by CORD-04 §4's re-heal union rather than by the rank rule. **The union is load-bearing security
here, not just convergence.**
**Fix direction.** Make the resolver's gates ban-aware. The ordering problem is real (you cannot
know who is banned before folding the banlist, nor who may write it before knowing who is banned),
so resolve it as a bounded two-pass where authority only ever *shrinks*: pass A settles the roster
as today and computes the banlist; pass B re-resolves roles/grants dropping editions whose author is
banned in pass A; then recompute the banlist under pass B's roster, keeping only bans still
authorized. Deterministic, terminates, no oscillation on mutual bans. **Consensus-affecting**: until
Armada ships the same rule, we will drop editions they honor.
## <a name="b3"></a>B3 — A rogue rotator compacts the banlist away
**Status: mitigated by B2.** The rotator this needed was the sockpuppet, which can no longer be
minted. A *legitimately* privileged rotator can still omit the banlist, and `EntityFloor` remains the
only defense for clients that already folded it — unchanged, and still worth a spec fix.
**High.** *Verified:* `aRogueRotatorCompactsTheBanAwayForEveryClientWithoutAFloor`.
A CORD-06 §3 compaction re-wraps one edition per entity and the *rotator* picks it, so a rotator can
decline to carry the banlist forward. Every edition it serves is genuine, so no signature check sees
the omission — `EntityFloor`'s own KDoc names this case ("clearing a banlist"). A banned member
cannot rotate, but the B2 puppet can.
The result is not a clean unban but a **split community**: clients that already folded the ban
refuse the rollback and still see it, fresh joiners have no floor and see no ban at all. Two
populations permanently disagreeing about who is a member, with no event either side can call
forged. Closing B2 removes the puppet and takes this with it; floors alone do not, since they only
protect people who were already there.
## <a name="b4"></a>B4 — The Refounding recipient set is attacker-inflatable
**Status: fixed (bounded).** The recipient set is capped, the owner-rooted roster is kept first, and
anything dropped is logged rather than silently truncated.
**High.** *Read:* `ConcordCommunitySession.allMembers()` / `emitChannelRumors`;
`AccountConcordActions.refoundConcordCommunity` step 2; `ConcordRefounding.buildBaseRekeyWraps`.
`allMembers()` = Guestbook joins `observedAuthors` roster owner, and it *is* the Refounding
recipient set. Both contributing sets are unbounded and both are attacker-writable: Guestbook joins
are self-signed (any key, no authority), and every author we decrypt is folded into
`observedAuthors` by design (CORD-02 §5, "observably present").
So each throwaway npub an attacker posts from, or announces, is one more mandatory NIP-44 blob in
the next Refounding, chunked 120 per event. 100k identities ⇒ ~100k encryptions and ~830 published
events — while they keep posting. **The attack inflates the cost of its own remedy**, and the remedy
is the only hard removal Concord has.
This is the cheapest thing on the list to fix and the only one that is not consensus-affecting: cap
the recipient set, prefer recent/attested members when over the cap, and surface what was dropped
(a silent truncation strands real members). Worth doing first.
## <a name="b5"></a>B5 — The ban is a per-pubkey display rule and the channel key is not revoked
**Status: inherent.** No client-side fix exists; a Refounding is the answer, which is why B4 mattered.
**High.** *Read:* `Account.consumeConcordRumorGated` (`isBanned(rumor.pubKey)`), `Account.isAcceptable`.
Writing to a channel needs the channel key, which the ban does not take away; the seal author is
whatever key the client feels like using. A malicious client therefore posts every message from a
fresh npub and `isBanned` never matches — moderation is whack-a-mole against an infinite identity
supply. Each message also costs every member two NIP-44 decrypts and two signature verifications
*before* the banlist check runs, and each fresh author inflates B4.
There is no client-side answer; only a Refounding rotates the key out from under them. That is the
correct design, which is why B4 matters so much.
## <a name="b6"></a>B6 — Channel history is deletable on a naive third-party relay
**Status: correct on our relay and pinned; external relays remain exposed.** Needs a CORD-01 spec note
and relay-selection guidance, not code.
**High, external.** *Verified (that we are safe):*
`geode/…/ConcordPlaneKeyDeletionTest.kt` (3 tests).
CORD-01 signs every wrap with the shared stream key, so on the wire a Concord channel is one author
publishing everything — and every member holds that author's secret. NIP-09 and NIP-62 authorize on
the outer `pubkey`. Read the obvious way, that hands any ex-member a one-event wipe of the whole
community's history, and geode's own guarantee ("a kind-5 from pubkey X cannot delete pubkey Y's
events") is vacuous inside a plane.
**On our relay it is refused, but only because of a rule written for something else:**
`Event.owner()` gives a kind-1059 to its *p-tag recipient* rather than its signer, and
`ConcordStreamEnvelope` stamps a freshly random p-tag on every wrap, so each wrap is owned by a
one-time key nobody holds. Both halves are load-bearing, neither was written for this, and either
one silently re-opens the hole — all three are now pinned, including a counterfactual showing a wrap
addressed to a *real* key is deletable by its holder.
A community publishes wherever its metadata points. Any relay that authorizes deletion by matching
`pubkey` still hands every ex-member the wipe button, and a Refounding protects only the future.
Worth a note in the CORD-01 spec and a line in the relay-selection guidance.
## <a name="b7"></a>B7 — The base-rekey plane is writable by every member
**Status: accepted.** Bounded work per wrap, no correctness impact.
**Low.** *Read:* `ConcordKeyDerivation.baseRekeyAddress`, `AccountConcordActions.drainConcordRekeys`.
The base-rekey address derives from `community_root`, so any member — banned included — can mint
valid wraps there. Authorization happens after the blobs are scanned, so a flood costs every member
a locator scan per blob on every revision tick. Bounded work per wrap and no correctness impact;
listed for completeness.
---
# Part C — interop and not-yet-shipped
## <a name="c1"></a>C1 — Banlist rank rule diverges from Armada
**Medium, known, deliberate.** See `docs/concord-banlist-rank-conformance.md`, already reported.
We enforce §3's rank half on the Banlist and Armada does not, so the two clients can show different
banlists. Shipped knowingly. Row 3 of that report ("a banned `BAN` holder unbans themselves") was
left open as a fixpoint-ordering question — B2 is the general form of it, and the fix proposed there
resolves both.
## <a name="c2"></a>C2 — Voice rooms are key-gated, not roster-gated
**Design-level; not currently reachable.** *Read:* `ConcordBrokerToken`, CORD-07 §2.
Downgraded from High on review: `ConcordBrokerToken` and `VoicePresence` are referenced nowhere
outside `quartz`, so Amethyst ships no Concord voice path yet. This is a note for whoever wires
one up, not a live hole.
A member proves voice-room membership by signing a NIP-98 kind-27235 request with the channel's
**derived voice signer key**, whose pubkey is the SFU room name. The broker is stateless and holds
no community secret, so it cannot consult the Control Plane and has no idea a banlist exists. A
banned member keeps that key until a Refounding, so they can join the voice room and stay in it.
Nothing on the client side can evict them — kicking them from the UI does not kick them from the SFU.
It would be the one place where a ban fails *audibly*, in real time, in front of everyone, so it is
worth designing the roster check in before shipping rather than after.
---
## <a name="armada"></a>Armada comparison (checked 2026-08-09)
Read against `gitlab.com/soapbox-pub/armada` at `src/concord-v2/`. Worth doing before shipping any of
this, and it changed two conclusions.
**B2 — they already do it, and we had it backwards.** `foldControlState` (`lib/control.ts`) runs the
same bounded two-pass: fold once, take the banlist, and if any edition was authored by someone on it,
re-fold with those editions excluded. Independently arrived at, same shape, same CORD-04 §4
justification in the comment. So this change brings us *into* line with Armada rather than out of it,
and the consensus warning in the earlier revision of this doc was wrong.
One real divergence remains, and it is ours to defend: Armada keeps **pass 1's** banlist as the final
word ("the first pass's Banlist stays the final word"), while we recompute the banlist in pass 2. So
a banned admin's mass-ban of everyone beneath them still stands in Armada and is dropped by us — the
`aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll` case. Their stated reason is to stop
the anti-roster erasing itself; ours is that an edition from a banned author should not survive its
own author's removal. Both are defensible; ours closes an attack theirs leaves open, and the
self-erasure they worry about is unreachable for us because the rank rule makes mutual bans
impossible (only someone who strictly outranks you can ban you). Worth raising with them.
**B1 — the same bug, unfixed, in exactly the same place.** `bootstrapHead` (`lib/version.ts:155`)
takes the highest version at or above the floor with no bound; `headCandidates` uses it for the
compaction arm; `pickHead` then raises the stored floor to whatever won. That is the whole
version-exhaustion chain. This is now the second bug both implementations share because both read
the same section the same way, and it deserves the same treatment as the rank rule: a written report.
**A1 — ours alone.** Armada gates invite creation on `CREATE_INVITE` in both the hook
(`useInvites2.ts`) and the page (`canCreateInvite`). We were the only client handing a banned member
a working invite button.
**A2 — different architecture, and it is better.** Armada's catch-up is **push**, not pull: a
privileged member sends a stranded member a direct invite carrying the fresher root
(`useDirectInvites2`, `catchUp`), so a human authorizes each re-admission. `useRekeyWatch2` merely
reports `{ stranded: boolean }` for the UI. They also ship `useBanSelfRemove2`: a banned member's own
client silently drops the community from their private list — network-silent, deliberately narrower
than rekey-exclusion, because "a rotation can be a mistake; a ban is a judgment". Our pull-from-my-own-
old-link design is what made the bypass possible, and their per-epoch bundle refresh is what would
have supplied the higher epoch to pull. Two ways forward: adopt a refresh of our own (restores
liveness, keeps the pull design and its risk), or move to their push model (safer, and it is what the
one existing implementation does).
**C1 — still open on their side.** `banlistGate` remains a bare `isAuthorized(roster, author, owner,
BAN)`: no rank check, no delta rule. The divergence from
`docs/concord-banlist-rank-conformance.md` is unchanged.
**A divergence in the other direction.** Armada's banlist takes only the gated head's content —
there is no §4 re-heal union. Ours unions in every authorized non-ancestor edition, which is what
defeats the genesis-fork laundering attempt in `BannedStaffEscalationTest`. So we honor concurrent
bans they drop. Worth a spec question about which is normative.
**A4 — shared gap.** No ban filter on typing there either.
**B4 — not established.** I could not locate a recipient-set bound in their rekey path, but I also
could not locate the recipient-set construction itself with confidence, so treat this as unchecked
rather than as a finding either way.
---
## Performance of the fixes
Measured on the JVM with a synthetic Control Plane (throwaway benchmark, not committed —
`ConcordCommunityState.fold` over 226 and 2059 editions, 200 reps after warmup). Pass A of the
two-pass resolver is byte-for-byte the old algorithm, so the single-pass rows below *are* the
before-numbers.
| Case | µs / fold |
|---|---|
| 226 editions, no bans | 1457 |
| 226 editions, 20 bans, none of them authors | 994 |
| 226 editions, 20 bans, one an author → pass B runs | 1881 |
| 2059 editions, no bans | 2194 |
| 2059 editions, 50 bans, none of them authors | 2001 |
| 2059 editions, 50 bans, one an author → pass B runs | 5697 |
| 2059 editions, with floors (B1's compaction arm) | 2015 |
Two things to take from it.
**B1 costs nothing measurable.** Trying the floor-anchored chain before the raw-version bootstrap
adds a per-entity version index on the compaction arm, but an entity carries a handful of editions,
and the floored fold measures the same as the unfloored one.
**B2 costs a further fold, but only when it can change the answer.** `resolve` skips pass B when
nobody is banned *or* when nobody banned ever authored a Control edition — the overwhelmingly common
shape, since bans land on plain members who hold no role and write nothing. Those rows show no
regression. When a banned member *did* author editions — a banned staffer, exactly the case B2 exists
for — the fold costs ~23× more, and one more pass again in the rare case where a banned member had
themselves authored a ban. That is the price of the fix and it is paid only by communities under the
attack.
**Worth knowing, unrelated to this work:** Amethyst re-folds the whole buffer from scratch on every
Control Plane change, and `resolve` runs once per held epoch inside `controlFloorsLocked` plus once
in `fold`, so a refresh is already several folds. Armada memoizes the fold by
`(community, owner, floors, snapshot, edition ids)`; we do not. That is the real optimization here,
it predates these fixes, and it would also absorb the pass-B cost. Left alone deliberately — it is a
change to make on its own merits, with its own measurements.
---
## What was NOT examined
This audit is bounded by what was opened. Checked and found sound: the wrap/seal envelope (no author
impersonation — `rumor.pubKey == seal.pubKey` and `rumor.verifyId()`), Concord chat edits
(`Note.latestConcordEdit` is author-gated, so a member cannot rewrite someone else's message), and
self-unban (B2).
Not looked at at all:
- **Private channels** (CORD-03 derived keys) — key delivery on grant, and channel-scoped rekey.
Note that no channel-scoped rekey *receive* path appears to exist: `drainConcordRekeys` handles
`ROOT_SCOPE` only, and `entry.privateChannels` is carried forward but never populated by a
delivery path. If that is right, the only removal Amethyst can perform is a full-community
Refounding — which is exactly what B4 makes expensive.
- **In-plane reactions and deletes** — the edit path is author-gated; the delete path was not read.
- **Guestbook kicks** (kind 3309) — the builder documents a KICK-bit + rank rule; the receive side
was not verified against it.
- Unread counts and notification triggers, media/upload references from messages, the NIP-53 nests
overlap, and the desktop client's Concord paths.
## What is left
1. **A2's liveness half** — decide whether a community re-mints its invite bundle at a stable
coordinate. Today nothing does, so stranded recovery never fires for anyone, and an owner evicted
by a rogue admin has no route back. Needs a spec answer before code.
2. **C1 / B2 interop** — tell Armada about the two-pass rule, as with the rank rule before it. The
divergence is now wider: we drop editions they honor whenever a privileged member is banned.
3. **B6** — a CORD-01 note that a plane's wraps must stay owned by a key nobody holds, plus guidance
that a relay authorizing NIP-09/62 by `pubkey` hands every ex-member a wipe button.
4. **B3's residue** — a legitimately privileged rotator can still omit an entity during compaction.
`EntityFloor` catches it for clients that were present; fresh joiners have nothing.
5. **A5** — a design pass on how "Ban" and "Remove from community" are presented, since they promise
very different things.
6. **The unexamined surfaces below**, particularly private channels — there appears to be no
channel-scoped rekey receive path at all, which would mean the full-community Refounding is the
only removal Amethyst can perform.
@@ -0,0 +1,242 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Why a soft-banned member cannot delete a Concord community's history from the relay and what
* keeps it that way.
*
* The worry is real. CORD-01 inverts NIP-59: every wrap on a plane is signed by the *shared stream
* key*, not by its author, and the true author only exists inside the encrypted seal. A member
* banned yesterday still derives `group_key("concord/channel", community_root, channel_id, epoch)`
* from the root they kept, so they can still sign events *as the channel itself*. If NIP-09 and
* NIP-62 authorized on the outer `pubkey`, [Nip09DeletionTest]'s "a kind-5 from pubkey X cannot
* delete pubkey Y's events" would be vacuous inside a plane: one event from any ex-member would
* erase the whole community's history.
*
* What stops it is [com.vitorpamplona.quartz.nip01Core.store.owner]: a kind-1059 gift wrap is
* controlled by its **p-tag recipient**, not its signer. Concord stamps a *freshly random* p-tag on
* every wrap ([ConcordStreamEnvelope.wrapSeal]), so each wrap is owned by a one-time key that
* nobody attacker, author, or owner ever holds. The channel is undeletable by construction.
*
* Both halves of that are load-bearing and neither was written for this reason, so both are pinned
* here: [theEphemeralPTagIsWhatMakesTheChannelUndeletable] fails the moment the p-tag becomes a
* real key, and the first two tests fail the moment ownership goes back to the signer.
*
* **Scope.** This is our relay's rule, not the protocol's. A community publishes wherever its
* metadata points, and a third-party relay that reads NIP-09 the naive way deletion authorized by
* matching `pubkey` hands every ex-member a wipe button for the whole channel. The protocol-level
* fix is the same one that already exists for everything else: a CORD-06 Refounding rotates the
* plane address, which protects the future but cannot restore what a relay already dropped.
*/
class ConcordPlaneKeyDeletionTest {
private lateinit var hub: InProcessRelays
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
hub = InProcessRelays()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
client = NostrClient(hub, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
hub.close()
}
private suspend fun query(filter: Filter): List<Event> {
val ch = Channel<Msg>(Channel.UNLIMITED)
val subId = "sub-${System.nanoTime()}"
client.subscribe(
subId,
mapOf(relayUrl to listOf(filter)),
object : SubscriptionListener {
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Msg.Ev(event))
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Msg.Eose)
}
},
)
val events = mutableListOf<Event>()
withTimeout(5000) {
while (true) {
when (val msg = ch.receive()) {
is Msg.Ev -> events += msg.event
Msg.Eose -> return@withTimeout
}
}
}
client.unsubscribe(subId)
return events
}
private sealed interface Msg {
data class Ev(
val event: Event,
) : Msg
object Eose : Msg
}
/** A public channel plane: derived from the community root, so every member holds its secret. */
private val communityRoot = RandomInstance.bytes(32)
private val channelId = RandomInstance.bytes(32)
private val plane = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch = 0)
/** The plane's own signer — what the banned member reconstructs from the root they kept. */
private fun planeSigner() = NostrSignerSync(KeyPair(privKey = plane.secretKey))
private suspend fun postAs(
author: NostrSignerInternal,
text: String,
createdAt: Long,
): Event {
val rumor = ChannelChat.message(author.pubKey, channelId.toHexKey(), epoch = 0, text = text, createdAt = createdAt)
return ConcordStreamEnvelope.wrap(rumor, plane, author, encrypted = true, createdAt = createdAt)
}
@Test
fun aPlaneKeyHolderCannotDeleteTheChannelsHistory() =
runBlocking {
val now = TimeUtils.now()
val bob = NostrSignerInternal(KeyPair())
val carol = NostrSignerInternal(KeyPair())
val history =
listOf(
postAs(bob, "hello", now),
postAs(carol, "hi bob", now + 1),
postAs(bob, "how's the project going?", now + 2),
)
history.forEach { assertEquals(true, client.publishAndConfirm(it, setOf(relayUrl)), "seed the channel history") }
assertEquals(3, query(Filter(authors = listOf(plane.publicKeyHex))).size, "three messages on the plane")
// The banned member still derives `plane`, and every wrap above IS authored by it — so
// this kind-5 satisfies a same-author check. It must still be refused.
val deletion = planeSigner().sign(DeletionEvent.build(history, createdAt = now + 10))
assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl)), "the relay accepts the event itself")
assertEquals(
3,
query(Filter(authors = listOf(plane.publicKeyHex), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP))).size,
"signing as the plane must NOT delete the community's messages",
)
}
@Test
fun aPlaneKeyHolderCannotVanishTheChannelPlane() =
runBlocking {
val now = TimeUtils.now()
val bob = NostrSignerInternal(KeyPair())
val history = listOf(postAs(bob, "one", now), postAs(bob, "two", now + 1))
history.forEach { client.publishAndConfirm(it, setOf(relayUrl)) }
assertEquals(2, query(Filter(authors = listOf(plane.publicKeyHex))).size)
// NIP-62 needs no per-event targeting: one event, and everything that pubkey published
// is gone. The sharpest version of the attack, and the same rule has to stop it.
val vanish = planeSigner().sign(RequestToVanishEvent.build(relayUrl, "", createdAt = now + 10))
assertEquals(true, client.publishAndConfirm(vanish, setOf(relayUrl)))
assertEquals(
2,
query(Filter(authors = listOf(plane.publicKeyHex), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP))).size,
"a kind-62 signed as the plane must not wipe the channel",
)
}
@Test
fun theEphemeralPTagIsWhatMakesTheChannelUndeletableSoDoNotMakeItMeaningful() =
runBlocking {
// The counterfactual, so the invariant is visible rather than incidental: ownership of a
// 1059 follows the p-tag, so a wrap addressed to a REAL key is deletable by whoever holds
// that key. Concord is safe only because `wrapSeal` stamps a fresh throwaway pubkey there.
// If that p-tag ever becomes something a member holds — a recipient, a channel id, a
// community id — every ex-holder of it can delete the plane's history.
val now = TimeUtils.now()
val mallory = NostrSignerInternal(KeyPair())
val addressedWrap =
planeSigner().signNormal<Event>(
now,
ConcordStreamEnvelope.KIND_WRAP,
arrayOf(arrayOf("p", mallory.pubKey)),
"not-a-real-seal",
)
assertEquals(true, client.publishAndConfirm(addressedWrap, setOf(relayUrl)))
assertEquals(1, query(Filter(ids = listOf(addressedWrap.id))).size)
val deletion = mallory.sign(DeletionEvent.build(listOf(addressedWrap), createdAt = now + 1))
assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl)))
assertEquals(
0,
query(Filter(ids = listOf(addressedWrap.id))).size,
"the p-tag recipient owns a 1059 — which is exactly why Concord's p-tag must stay random",
)
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.Log
/**
* Resolves the owner-rooted authority state of a Concord community from its
@@ -132,12 +133,101 @@ data class AuthorityResolver private constructor(
}
companion object {
private const val TAG = "ConcordAuthorityResolver"
/** The owner's rank — supreme and unremovable. No Role may claim it. */
const val OWNER_RANK = 0L
/**
* How many times [resolve] will re-fold chasing a stable banlist. Real communities settle on
* the first or second the mask only moves when a banned member authored a *ban*, and it
* stops moving as soon as those are gone. The cap is a termination backstop for an
* adversarial edition set, not a tuning knob.
*/
private const val MAX_BAN_RESOLUTION_PASSES = 4
/**
* The owner-rooted authority state of a community, with the banlist honored **against the
* Control Plane itself** (CORD-04 §4: a reader "drops every event from a banned npub —
* message, reaction, edit, or authority action").
*
* This is a bounded two-pass, because the rule is circular as stated: you cannot know who is
* banned until you fold the Banlist, and you cannot decide who may write the Banlist without
* knowing who is banned. `docs/concord-banlist-rank-conformance.md` §4 row 3 flagged that to
* the spec authors and left it open. We resolve it by making authority only ever **shrink**:
*
* - **Pass A** resolves exactly as before, ban-blind, and yields a candidate banlist.
* - **Pass B** re-resolves with every author in that banlist treated as unauthorized, for
* roles, grants and the banlist alike.
*
* Two passes, always, so it terminates by construction pass B never feeds back. It cannot
* oscillate on mutual bans either, because the rank rule makes them unreachable: only a
* member who strictly outranks you may ban you, and you cannot outrank them back.
*
* **This cascades, deliberately.** Every edition a banned member ever authored is dropped,
* including grants they made while in good standing so banning an admin also demotes
* everyone that admin promoted. That is the literal reading of §4, and it is the point: the
* escalation in `docs/concord-soft-ban-audit.md` B2 was a banned staffer minting a fresh,
* un-banned npub and acting through it, and dropping the grant is what kills the puppet. The
* cost is that a legitimate promotion by a later-banned admin vanishes too, and the owner has
* to re-issue it.
*
* **Consensus-affecting.** Armada gates the Control Plane on role-derived permissions alone,
* so until it ships the same rule the two clients can disagree about any community where a
* privileged member was banned.
*/
fun resolve(
editions: Collection<ControlEdition>,
ownerPubKey: String,
): AuthorityResolver {
val passA = resolveOnce(editions, ownerPubKey, bannedAuthors = emptySet())
// A further pass costs a whole fold, so skip it unless it could change something. Nobody
// banned, or nobody banned who ever wrote to the Control Plane — the overwhelmingly common
// shape, since most bans land on plain members who hold no role and author no editions —
// and the next pass is provably identical to this one. Armada's fold checks the same.
if (passA.banned.isEmpty()) return passA
if (editions.none { it.author.lowercase() in passA.banned }) return passA
// Iterate to a fixpoint where the mask a pass was resolved UNDER equals the banlist that
// pass produced. Stopping at two passes leaves those two disagreeing, and the disagreement
// is not cosmetic: a moderator whose only ban came from an admin the owner banned
// concurrently is released by pass 2 — correctly — but pass 2 dropped her editions too,
// because she was on pass 1's list. The fold then reports her as a moderator in good
// standing whose promotions have silently vanished, and it does so deterministically, so
// she never gets them back.
//
// The mask cannot simply be assumed to shrink: masking an author can strip a THIRD
// member's role, dropping their rank to "roleless", which lets a junior BAN holder who
// could not previously reach them ban them after all. So this is bounded rather than
// proven monotone, and it keeps the last pass it computed if it somehow does not settle —
// still strictly better than the two-pass answer, and it always terminates.
var mask = passA.banned
var result = passA
repeat(MAX_BAN_RESOLUTION_PASSES) {
result = resolveOnce(editions, ownerPubKey, bannedAuthors = mask)
if (result.banned == mask) return result
mask = result.banned
}
// Exhausted the cap without settling. The returned roster was folded under a mask that is
// no longer the banlist beside it, so this is reported rather than swallowed — the same
// reasoning as EditionFold.LOG_GAP: an unsettled fold is either an adversarial edition set
// or a rule of ours that does not converge, and both are things a reader wants to know.
Log.w(TAG) {
"Banlist resolution did not settle in $MAX_BAN_RESOLUTION_PASSES passes for owner $ownerPubKey " +
"(${editions.size} editions, ${result.banned.size} banned): keeping the last pass"
}
return result
}
/**
* One resolution pass. [bannedAuthors] are treated as holding no authority at all their
* role, grant and banlist editions are dropped rather than merely being unable to act on
* others. Empty on pass A; pass A's banlist on pass B. See [resolve].
*/
private fun resolveOnce(
editions: Collection<ControlEdition>,
ownerPubKey: String,
bannedAuthors: Set<String>,
): AuthorityResolver {
val ownerLower = ownerPubKey.lowercase()
@@ -191,6 +281,7 @@ data class AuthorityResolver private constructor(
): Boolean {
val author = e.author.lowercase()
if (author == ownerLower) return true
if (author in bannedAuthors) return false
if (!holdsManageRoles(author)) return false
val authorRank = rankOf(author) ?: return false
val r = ConcordJson.decodeOrNull<RoleEntity>(e.content) ?: return false
@@ -223,6 +314,7 @@ data class AuthorityResolver private constructor(
fun grantGate(e: ControlEdition): Boolean {
val granter = e.author.lowercase()
if (granter == ownerLower) return true
if (granter in bannedAuthors) return false
if (!holdsManageRoles(granter)) return false
val granterRank = rankOf(granter) ?: return false
val g = ConcordJson.decodeOrNull<GrantEntity>(e.content) ?: return false
@@ -269,7 +361,10 @@ data class AuthorityResolver private constructor(
// a concurrent ban is never lost, while an on-chain unban still takes effect.
val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST }
fun banGate(e: ControlEdition): Boolean = e.author.lowercase() == ownerLower || effectivePermissionsOf(e.author.lowercase()).has(ConcordPermissions.BAN)
fun banGate(e: ControlEdition): Boolean {
val author = e.author.lowercase()
return author == ownerLower || (author !in bannedAuthors && effectivePermissionsOf(author).has(ConcordPermissions.BAN))
}
val authorizedBanlist = allBanlist.filter(::banGate)
// CORD-04 §3's rank rule binds "every action", and it names banning as its example ("an
@@ -292,6 +387,7 @@ data class AuthorityResolver private constructor(
// owner is never a valid target — not even for themselves.
if (target == ownerLower) return false
if (author == ownerLower) return true
if (author in bannedAuthors) return false
if (!effectivePermissionsOf(author).has(ConcordPermissions.BAN)) return false
val authorRank = rankOf(author) ?: return false
val targetRank = rankOf(target) ?: Long.MAX_VALUE // no roles ⇒ lowest authority
@@ -150,9 +150,63 @@ object EditionFold {
floorVersion: Long,
): ControlEdition? =
editions
.filter { it.version >= floorVersion }
.filter { it.version >= floorVersion && it.version - floorVersion <= MAX_COMPACTION_VERSION_JUMP }
.minWithOrNull(compareByDescending<ControlEdition> { it.version }.thenBy { it.rumorId })
/**
* How far above the floor the compaction arm will follow an edition in one step.
*
* The arm trades contiguity for cross-epoch tolerance, which made VERSION the only contest an
* edition had to win so a single authorized edition at `version = Long.MAX_VALUE` used to
* become an entity's permanent head: it won the arm, `authorizedHeads` raised the floor to
* `Long.MAX_VALUE`, and from there no honest edition could ever exceed the floor again. Even a
* Refounding that dropped the poison did not help, because nothing was then offered at or above
* the floor and the fold fell back to [EntityFloor.known] the poison itself. See B1 in
* `docs/concord-soft-ban-audit.md`.
*
* A compacted head is legitimately ahead of the floor by however many editions the entity gained
* while we were away a chain's worth, not 2^63. This bound is deliberately far above any real
* community (a channel renamed a thousand times a day for three years stays under it) and far
* below the point where the version space can be exhausted. Anything beyond it is not a
* compaction we missed; it is someone reaching for the ceiling, and it is treated as a gap.
*/
const val MAX_COMPACTION_VERSION_JUMP = 1_000_000L
/**
* The floor-anchored chain head among [editions], or null when nothing connects to [floor].
*
* The same anchor-then-walk the main path uses, factored out so the compaction arm can try it
* first: the anchor is the floor's own edition (same version AND hash) or its immediate
* successor citing that hash, then the walk climbs while each `version + 1` cites the current
* head. Reports no gap a null here means "fall back", not "refuse".
*/
private fun chainHead(
editions: List<ControlEdition>,
floor: EntityFloor,
): ControlEdition? {
val byVersion = HashMap<Long, MutableList<ControlEdition>>()
for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e)
val lowest = byVersion.keys.filter { it >= floor.version }.minOrNull() ?: return null
val winner = byVersion[lowest]?.minByOrNull { it.rumorId } ?: return null
var head =
when (lowest) {
floor.version -> winner.takeIf { it.hashHex == floor.hashHex }
floor.version + 1 -> winner.takeIf { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex }
else -> null
} ?: return null
while (true) {
val next =
byVersion[head.version + 1]
?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex }
?.minByOrNull { it.rumorId }
?: break
head = next
}
return head
}
/**
* Groups mixed [editions] by entity id and folds each to its head, honoring the
* per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]).
@@ -210,10 +264,16 @@ object EditionFold {
// to the compacted head. Presence of the entity in the snapshot selects the ARM;
// version selects the HEAD, over every edition we hold and not just the subset.
if (floor != null && snapshot != null && editions.any { it.rumorId in snapshot }) {
// Chain first, bootstrap only as the fallback. The arm exists for the case where the
// offered head genuinely cannot be connected — but when it CAN be, the connected head is
// strictly better evidence than "highest number wins", and preferring it denies a stray
// high-version edition its free win in every ordinary fold. The bootstrap keeps the
// cross-epoch case working, now bounded by MAX_COMPACTION_VERSION_JUMP.
chainHead(editions, floor)?.let { return it }
return bootstrapHead(editions, floor.version)
?: run {
// Nothing at or above the floor was served: the head we already accepted
// vanished from the offered set — withheld, so fail closed.
// Nothing admissible at or above the floor was served: the head we already
// accepted vanished from the offered set — withheld, so fail closed.
onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version })
floor.known
}
@@ -46,12 +46,24 @@ object ConcordStrandedRecovery {
* True when [bundle], resolved at [entry]'s stored invite link, proves we were
* left behind: it must describe the same community and sit at a strictly higher
* epoch. Same or lower is a no-op (we are current, or the bundle is stale).
*
* [bannedAtCurrentEpoch] is the caller's answer to "does the community, as I fold
* it right now, have me on its banlist?" — and a `true` refuses the recovery
* outright. It is a required argument rather than a caller-side `if` because
* getting it wrong turns this mechanism inside out: recovery exists so a member
* *wrongly* omitted from a rotation can catch up, but the test it performs (a
* higher epoch at a link whose unlock token an ex-member keeps forever) cannot
* tell that member apart from one the community deliberately removed. Without
* this, a Refounding the only hard removal Concord has is undone by our own
* background sweep a few minutes later.
*/
fun isStranded(
entry: ConcordCommunityListEntry,
bundle: CommunityInvite,
bannedAtCurrentEpoch: Boolean,
): Boolean =
entry.inviteRef != null &&
!bannedAtCurrentEpoch &&
entry.inviteRef != null &&
bundle.communityId.equals(entry.id, ignoreCase = true) &&
bundle.rootEpoch > entry.rootEpoch
@@ -73,8 +85,9 @@ object ConcordStrandedRecovery {
fun mergeForward(
entry: ConcordCommunityListEntry,
bundle: CommunityInvite,
bannedAtCurrentEpoch: Boolean,
): ConcordCommunityListEntry? {
if (!isStranded(entry, bundle)) return null
if (!isStranded(entry, bundle, bannedAtCurrentEpoch)) return null
// Bank the epoch we are leaving with its control_pk, so its Control Plane
// stays re-subscribable for the anti-rollback floor (a split epoch's address
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.concord.cord06Rekey
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
@@ -123,11 +124,12 @@ object ConcordRefounding {
recipientsXOnly: List<HexKey>,
staffXOnly: Set<HexKey>,
createdAt: Long,
ownerPubKey: HexKey,
): RefoundingBuild {
val newEpoch = rootEpoch + 1
val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot)
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys)
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, ownerPubKey)
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch)
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
@@ -166,19 +168,41 @@ object ConcordRefounding {
priorWraps: List<Event>,
priorControlKeys: ControlPlaneKeys,
newControlKeys: ControlPlaneKeys,
ownerPubKey: HexKey,
): List<Event> {
// entity coordinate -> (head edition, its verified seal)
val heads = HashMap<String, Pair<ControlEdition, Event>>()
// entity coordinate -> every edition we can open, paired with its verified seal.
val byCoordinate = HashMap<String, MutableList<Pair<ControlEdition, Event>>>()
for (wrap in priorWraps) {
val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKeys) ?: continue
val edition = ControlEdition.fromRumor(opened.rumor) ?: continue
val coord = edition.entityKind.wire + ":" + edition.entityIdHex
val current = heads[coord]
if (current == null || edition.version > current.first.version) {
heads[coord] = edition to opened.seal
}
byCoordinate.getOrPut(coord) { ArrayList() }.add(edition to opened.seal)
}
return heads.values.map { (_, seal) -> ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt) }
// The head to carry forward is the one every READER honors — the authority-gated head — not
// the highest version and not the bare structural chain head.
//
// Raw highest version made an honest rotator the delivery mechanism for a disconnected stray:
// an edition minted at an arbitrary version never joins the chain, but it won that comparison
// and was re-wrapped into the new epoch as the entity's whole history (B1 in
// `docs/concord-soft-ban-audit.md`). The bare chain walk is *worse*, and this is the trap:
// with no floor it anchors at the lowest-version edition carrying no `prev`, and after a prior
// compaction the real head's `prev` dangles by design — so a forged `version = 1, prev = null`
// decoy outranks a genuine v50→v52 chain and, because nothing here checks signatures, becomes
// the entity's entire carried-forward state. A forged empty banlist would erase every ban.
//
// Gating on the owner-rooted roster is the only selection that cannot be gamed by an
// unprivileged author, and it is exactly what ConcordCommunityState.fold would seat, so the
// compacted epoch starts where the previous one left off.
val editions = byCoordinate.values.flatten()
val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, ownerPubKey)
val out = ArrayList<Event>(honored.size)
for ((_, floor) in honored) {
val head = floor.known ?: continue
val seal = editions.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue
out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt))
}
return out
}
/**
@@ -0,0 +1,397 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* **B2 in `docs/concord-soft-ban-audit.md` regression guard.** What a soft-banned staffer used to
* be able to do to a community, and can no longer. Every test here failed before the two-pass rule
* in [AuthorityResolver.resolve] and passes after it.
*
* The asymmetry they were written to pin: [ConcordCommunityState.fold] gates METADATA / CHANNEL /
* INVITE through `authority.hasPermission`, which is `!isBanned && `, but ROLE, GRANT and BANLIST
* were gated *inside* [AuthorityResolver.resolve] by `holdsManageRoles` / `bitsOf` /
* `effectivePermissionsOf` none of which consulted the banlist, nor could they as written, since
* the roles/grants fixpoint ran before `banned` was computed at all. Half the Control Plane honored
* a ban and half was structurally blind to it, so a banned member still holding `control_root` kept
* full authority over the roster: they banned everyone beneath them, revoked the surviving
* moderators, retired the roles under them, and minted a fresh un-banned npub that passed every
* ban-aware gate and finished the job.
*
* The fix resolves the ordering by making authority only ever shrink across two passes see
* [AuthorityResolver.resolve]. Note that a chain-local rule ("the author must not be banned by the
* state their edition chains from") would NOT have been enough:
* [aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan] forks at genesis so no
* parent ever mentions the ban, and CORD-04 §4's re-heal union would carry it in anyway. The rule
* had to bind the union too, which is why it is expressed as a whole-pass mask.
*
* Three tests pin behaviour the fix had to *preserve* rather than change:
* [selfUnbanIsStillRefused], [aJuniorPuppetCannotLiftASeniorsBan], and
* [aBanByOneAdminDoesNotDropTheGrantsOfAnother]. One pins the cost it deliberately accepts:
* [banningAnAdminAlsoDemotesEveryoneThatAdminPromoted].
*/
class BannedStaffEscalationTest {
private val owner = "0f".repeat(32)
private val alice = "a1".repeat(32) // Admin, position 1 — the member who gets banned
private val bob = "b2".repeat(32) // Mod, position 5
private val carol = "c3".repeat(32) // plain member, no role
private val puppet = "e5".repeat(32) // a fresh npub alice controls
private val adminRole = "11".repeat(32)
private val modRole = "22".repeat(32)
private val puppetRole = "33".repeat(32)
private val banlistEntity = "44".repeat(32)
private val channelEntity = "55".repeat(32)
private val metadataEntity = "66".repeat(32)
private val bobGrantEntity = "32".repeat(32)
private val puppetGrantEntity = "35".repeat(32)
// MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|KICK|BAN|CREATE_INVITE = 1+2+4+8+16+64
private val adminJson = """{"name":"Admin","position":1,"permissions":"95"}"""
private val modJson = """{"name":"Mod","position":5,"permissions":"24"}""" // KICK|BAN
private fun edition(
kind: ControlEntityKind,
entity: String,
version: Long,
prev: ByteArray?,
content: String,
author: String,
rumorId: String,
) = ControlEdition(kind, entity.hexToByteArray(), version, prev, null, content, author, rumorId, 0)
private fun role(
id: String,
json: String,
author: String = owner,
version: Long = 0,
prev: ByteArray? = null,
) = edition(ControlEntityKind.ROLE, id, version, prev, json, author, "role-$id-$version-$author")
private fun grant(
coordinate: String,
member: String,
roleIds: List<String>,
author: String,
version: Long = 0,
prev: ByteArray? = null,
) = edition(
ControlEntityKind.GRANT,
coordinate,
version,
prev,
"""{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""",
author,
"grant-$coordinate-$version-$author",
)
private fun banlist(
author: String,
version: Long,
prev: ByteArray?,
vararg banned: String,
) = edition(
ControlEntityKind.BANLIST,
banlistEntity,
version,
prev,
"[${banned.joinToString(",") { "\"$it\"" }}]",
author,
"ban-$version-$author",
)
private fun channel(
json: String,
author: String,
version: Long,
prev: ByteArray?,
) = edition(ControlEntityKind.CHANNEL, channelEntity, version, prev, json, author, "chan-$version-$author")
private fun metadata(
json: String,
author: String,
version: Long,
prev: ByteArray?,
) = edition(ControlEntityKind.METADATA, metadataEntity, version, prev, json, author, "meta-$version-$author")
private val channelV0 = channel("""{"name":"general"}""", owner, 0, null)
private val metadataV0 = metadata("""{"name":"My Community"}""", owner, 0, null)
private val bobGrantV0 = grant(bobGrantEntity, bob, listOf(modRole), owner)
private val modRoleV0 = role(modRole, modJson)
/** The owner-authored community every test starts from: two roles, two grants, a channel, metadata. */
private fun community() =
mutableListOf(
role(adminRole, adminJson),
modRoleV0,
grant("31".repeat(32), alice, listOf(adminRole), owner),
bobGrantV0,
channelV0,
metadataV0,
)
/** The owner bans alice. Genesis of the banlist, so every test can fork or chain off it. */
private val ownerBansAlice = banlist(owner, 0, null, alice)
/** Alice, already banned, mints a role just below herself and hands it to a fresh npub. */
private fun aliceMintsAPuppet() =
listOf(
role(puppetRole, """{"name":"Puppet","position":2,"permissions":"95"}""", author = alice),
grant(puppetGrantEntity, puppet, listOf(puppetRole), author = alice),
)
@Test
fun aBanStripsTheAuthorityCheckedByFoldButNotTheOneCheckedByTheResolver() {
val r = AuthorityResolver.resolve(community() + ownerBansAlice, owner)
assertTrue(r.isBanned(alice), "the owner's ban lands")
assertFalse(r.hasPermission(alice, ConcordPermissions.MANAGE_ROLES), "the ban-aware check refuses her")
// effectivePermissions still reports what her ROLES say — that is its job, and the members
// screen reads it to label her. What changed is that the resolver's own ROLE/GRANT/BANLIST
// gates no longer consult it for a banned author; they drop the edition outright.
assertTrue(
r.effectivePermissions(alice).has(ConcordPermissions.MANAGE_ROLES),
"the role-derived view is unchanged — only what it authorizes is",
)
}
@Test
fun aBannedAdminPromotesAFreshSockpuppetToAdmin() {
val r = AuthorityResolver.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner)
assertEquals(null, r.rank(puppet), "the banned admin's role and grant editions are both dropped")
assertFalse(r.isBanned(puppet), "the puppet itself is a clean npub — it is never banned, just powerless")
assertFalse(
r.hasPermission(puppet, ConcordPermissions.MANAGE_CHANNELS),
"a banned member cannot mint authority it no longer has to give",
)
}
@Test
fun theSockpuppetDeletesEveryChannelAndRewritesTheMetadata() {
val editions =
community() + ownerBansAlice + aliceMintsAPuppet() +
// A channel tombstone is terminal — CORD-03: the id is never reused.
channel("""{"name":"general","deleted":true}""", puppet, 1, channelV0.hash) +
metadata("""{"name":"Owned by the guy you banned"}""", puppet, 1, metadataV0.hash)
val state = ConcordCommunityState.fold(editions, owner)
assertEquals(1, state.channels.size, "the puppet holds nothing, so its tombstone is inert")
assertEquals("My Community", state.metadata?.name, "and the community keeps its identity")
}
@Test
fun theSockpuppetBansEveryMemberBeneathIt() {
val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash, alice, bob, carol)
val r = AuthorityResolver.resolve(editions, owner)
assertFalse(r.isBanned(bob), "the puppet's banlist edition is unauthorized, so the moderator stands")
assertFalse(r.isBanned(carol), "and so do the plain members")
}
@Test
fun aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll() {
val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash, alice, bob, carol)
val r = AuthorityResolver.resolve(editions, owner)
assertTrue(r.isBanned(alice), "her own ban stands — it was the owner's")
assertFalse(r.isBanned(bob), "banGate now drops a banned author's edition outright")
assertFalse(r.isBanned(carol), "same")
}
@Test
fun aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan() {
// The same attack as above, except her edition does NOT chain onto the edition that banned
// her — it forks at genesis. So a rule that only asks "was the author banned by this
// edition's parent?" never sees her ban, and CORD-04 §4's re-heal union carries her bans in
// regardless. Any fix has to bind the union, not just the chain.
val editions = community() + ownerBansAlice + banlist(alice, 0, null, bob, carol)
val r = AuthorityResolver.resolve(editions, owner)
assertTrue(r.isBanned(alice), "the owner's ban survives the fork — the union is down-only")
assertFalse(r.isBanned(bob), "the fix binds the UNION too: her fork is dropped before it can be healed in")
assertFalse(r.isBanned(carol), "same")
}
@Test
fun aBannedAdminRevokesTheSurvivingModerators() {
val editions = community() + ownerBansAlice + grant(bobGrantEntity, bob, emptyList(), author = alice, version = 1, prev = bobGrantV0.hash)
val r = AuthorityResolver.resolve(editions, owner)
assertEquals(5, r.rank(bob), "a banned admin's revoke is dropped, so the moderator keeps their role")
assertTrue(r.hasPermission(bob, ConcordPermissions.BAN), "and keeps the authority that comes with it")
}
@Test
fun aBannedAdminDeletesEveryRoleBeneathThem() {
val tombstone = role(modRole, """{"name":"Mod","position":5,"permissions":"24","deleted":true}""", author = alice, version = 1, prev = modRoleV0.hash)
val r = AuthorityResolver.resolve(community() + ownerBansAlice + tombstone, owner)
assertEquals(5, r.roles()[modRole]?.position, "a banned admin's tombstone is dropped, so the role survives")
assertEquals(5, r.rank(bob), "and its holders keep their standing")
}
@Test
fun selfUnbanIsStillRefused() {
// docs/concord-banlist-rank-conformance.md §4 row 3, the half that IS closed: the delta rule
// gates removals too, and strict outranking means nobody outranks themselves.
val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash)
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a banned member may not lift their own ban")
}
@Test
fun aJuniorPuppetCannotLiftASeniorsBan() {
// The puppet sits at position 2 and alice at 1, and no edition may claim a position at or
// above its own signer — so her delegation chain can only ever descend. Nothing she mints
// can outrank her, and so nothing she mints can unban her.
val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash)
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator")
}
@Test
fun aForkedBanlistThatOmitsHimCannotLaunderTheBanAway() {
// A malicious client is not limited to what the ban/unban verb will author. The sharpest
// hand-crafted route does not try to REMOVE his ban — removal is what the strict-outrank-self
// rule guards — it forks at genesis and simply never mentions him, at a version high enough
// to win the head fold. The head's own effective list then never carried his ban, so there is
// nothing to remove and the rank rule never fires.
//
// §4's re-heal is what closes it: the owner's edition is authorized and is NOT on the forked
// head's back-chain, so it is unioned back in as a concurrent ban.
val editions = community() + ownerBansAlice + banlist(alice, 99, null, carol)
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the re-heal union must put the owner's ban back")
}
@Test
fun aPrivateBanlistChainOfHisOwnCannotLaunderTheBanAway() {
// The same idea two editions deep, so the winning head has a clean ancestry entirely of his
// own making. Ancestry is walked over the full pool, so the owner's ban is still recognised
// as a concurrent fork rather than a superseded ancestor.
val mine = banlist(alice, 50, null)
val editions = community() + ownerBansAlice + mine + banlist(alice, 51, mine.hash)
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a self-authored chain must not launder the ban away")
}
@Test
fun aRogueRotatorCompactsTheBanAwayForEveryClientWithoutAFloor() {
// The route that does work, and the one no signature check can catch. A CORD-06 §3 compaction
// re-wraps ONE edition per entity and the ROTATOR picks it, so a rotator can simply not carry
// the banlist forward. Every edition it serves is genuine; the ban is erased by omission.
//
// A banned member cannot rotate (drainConcordRekeys gates the rotator on hasPermission, which
// is ban-aware) — but the puppet minted above is not banned, and it can. EntityFloor is the
// whole defense, so this splits the community in two: clients that already folded the ban
// refuse the rollback, while fresh joiners have no floor to refuse with and see no ban at all.
val editions = community() + ownerBansAlice
val floors = ConcordCommunityState.authorizedHeads(editions, owner)
val compacted = editions.filter { it.entityKind != ControlEntityKind.BANLIST }
assertFalse(
ConcordCommunityState.fold(compacted, owner).authority.isBanned(alice),
"ESCALATION: a fresh joiner holds no floor, so the omitted ban simply never existed",
)
assertTrue(
ConcordCommunityState.fold(compacted, owner, floors).authority.isBanned(alice),
"a client that already folded the ban must refuse the rollback",
)
}
@Test
fun banningAnAdminAlsoDemotesEveryoneThatAdminPromoted() {
// The deliberate cascade, pinned because it is surprising and because it is the whole point.
// CORD-04 §4 drops every event from a banned npub, authority actions included, so a grant
// they made while in good standing goes too. That is what kills a sockpuppet minted moments
// before the ban — and the same rule costs the owner a legitimate promotion, which they have
// to re-issue. See B2 in docs/concord-soft-ban-audit.md.
val promoted = grant("36".repeat(32), carol, listOf(modRole), author = alice)
val before = AuthorityResolver.resolve(community() + promoted, owner)
assertEquals(5, before.rank(carol), "while alice is in good standing, her grant stands")
val after = AuthorityResolver.resolve(community() + promoted + ownerBansAlice, owner)
assertEquals(null, after.rank(carol), "banning alice retroactively drops the grant she authored")
}
@Test
fun aBanByOneAdminDoesNotDropTheGrantsOfAnother() {
// The cascade must follow the banned author, not spread. Bob is untouched by alice's ban, so
// everything he authored keeps standing.
val carolByBob = grant("37".repeat(32), carol, listOf(modRole), author = bob)
// bob is a Mod at position 5 and the role he hands out is that same position, so the grant is
// only honored when authored by someone who outranks it — the owner does, bob does not.
val carolByOwner = grant("38".repeat(32), carol, listOf(modRole), author = owner)
val r = AuthorityResolver.resolve(community() + ownerBansAlice + carolByBob + carolByOwner, owner)
assertTrue(r.isBanned(alice), "alice is the only one banned")
assertEquals(5, r.rank(bob), "bob is untouched")
assertEquals(5, r.rank(carol), "and the owner's grant of carol stands")
}
@Test
fun aMemberReleasedByTheSecondPassKeepsTheEditionsTheyAuthored() {
// The mask a pass resolves UNDER has to equal the banlist that pass produces, or the fold
// reports a state that contradicts itself. Concretely: the rogue admin bans a moderator while
// the owner concurrently bans the rogue. The moderator is correctly released — the only ban on
// her came from someone who turned out to be banned — but a fold that stops after two passes
// has already dropped her editions, because she was on the FIRST pass's list. She then reads
// as a moderator in good standing whose promotions silently vanished, deterministically and
// forever. resolve() iterates until the two agree.
val juniorRole = "23".repeat(32)
val seniorRole = "24".repeat(32)
val editions =
community() +
// the baseline Mod role carries no MANAGE_ROLES, so give bob one that can grant
role(seniorRole, """{"name":"Senior","position":5,"permissions":"95"}""") +
grant(bobGrantEntity, bob, listOf(seniorRole), author = owner, version = 1, prev = bobGrantV0.hash) +
role(juniorRole, """{"name":"Junior","position":9,"permissions":"8"}""") +
// bob promotes carol himself, while in good standing
grant("39".repeat(32), carol, listOf(juniorRole), author = bob) +
// the rogue admin bans bob...
banlist(alice, 0, null, bob) +
// ...while the owner concurrently bans the rogue, never naming bob
ownerBansAlice
val r = AuthorityResolver.resolve(editions, owner)
assertTrue(r.isBanned(alice), "the owner's ban of the rogue stands")
assertFalse(r.isBanned(bob), "and the rogue's ban of the moderator falls with them")
assertEquals(5, r.rank(bob), "the released moderator keeps their own role")
assertEquals(9, r.rank(carol), "and the promotion they authored survives with them")
}
}
@@ -0,0 +1,223 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* **B1 in `docs/concord-soft-ban-audit.md` regression guard.** A single Control Plane edition at
* `version = Long.MAX_VALUE` used to pin its entity to the author's content permanently, for every
* client that held a floor for it. These tests failed before the fix and pass after it.
*
* The chain walk was never the weakness it advances only to `head.version + 1` citing the head's
* hash, so an inflated version is unreachable and a fresh joiner was unaffected. The weakness was the
* **compaction arm** of `EditionFold.foldEntity`: once a client held a floor for an entity and that
* entity appeared in the epoch snapshot (which `ConcordCommunityState.fold` always builds from the
* editions handed to it), the head came from the raw-version bootstrap *highest version at or above
* the floor*, with no `prev`, no hash, and no contiguity. Version was then the whole contest, and
* `Long.MAX_VALUE` won it forever:
*
* 1. the poison became the head, so the entity showed the attacker's content;
* 2. `authorizedHeads` raised the entity's floor to `Long.MAX_VALUE`;
* 3. no honest edition could ever exceed that floor, so the entity could never be repaired;
* 4. a Refounding that dropped the poison did not help either nothing was then offered at or above
* the floor, so the fold reported a gap and fell back to `EntityFloor.known`, which *was* the poison.
*
* Two changes close it, and both are pinned below. The arm now tries the floor-anchored **chain**
* first and only falls back to the raw-version bootstrap when nothing connects, so a stray never wins
* a fold where the honest chain is present; and the bootstrap will not follow a jump larger than
* [EditionFold.MAX_COMPACTION_VERSION_JUMP], so the version space cannot be exhausted in one step.
* [aGenuineCompactionJumpIsStillFollowed] pins the tolerance the arm exists for, so the bound cannot
* be tightened into breaking CORD-06 §3.
*
* Note who the attacker is. Every test here is authored by **bob, a current and legitimately granted
* moderator** not a banned member, not a sockpuppet. Any holder of the entity's permission bit can
* could do this at any time, and demoting or banning them afterwards changed nothing, because the
* damage was already in every client's floor. `ConcordRefounding.compactControlPlane` also selected
* the head per entity by raw highest version, which made an honest rotator the delivery mechanism
* it now picks the chain head instead.
*
* The banlist was the one entity that survived, and by accident: `AuthorityResolver` folds it with
* its own floor-less chain walk and then re-heals the union across authorized editions, so an
* honest ban landed even when the head was poisoned. [aPoisonedBanlistStillAcceptsTheOwnersBan]
* keeps pinning that, because it was the only thing standing between this bug and a permanently
* unmoderatable community.
*/
class ControlPlaneVersionExhaustionTest {
private val owner = "0f".repeat(32)
private val bob = "b2".repeat(32)
private val modRole = "22".repeat(32)
private val metadataEntity = "66".repeat(32)
private val channelEntity = "55".repeat(32)
private val banlistEntity = "44".repeat(32)
private fun edition(
kind: ControlEntityKind,
entity: String,
version: Long,
prev: ByteArray?,
content: String,
author: String,
rumorId: String,
) = ControlEdition(kind, entity.hexToByteArray(), version, prev, null, content, author, rumorId, 0)
/** bob holds exactly one bit, granted by the owner, entirely legitimately. */
private fun communityWhereBobHolds(
permissions: String,
vararg rest: ControlEdition,
) = listOf(
edition(ControlEntityKind.ROLE, modRole, 0, null, """{"name":"Mod","position":5,"permissions":"$permissions"}""", owner, "role-mod"),
edition(ControlEntityKind.GRANT, "32".repeat(32), 0, null, """{"member":"$bob","role_ids":["$modRole"]}""", owner, "grant-bob"),
) + rest
@Test
fun oneEditionAtMaxVersionNoLongerPinsTheMetadata() {
val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0")
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0)
// A client that has folded this community once holds a floor for the metadata entity.
val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner)
assertEquals(0, floorsBefore[metadataEntity]?.version, "an ordinary floor at the genesis edition")
val poison = edition(ControlEntityKind.METADATA, metadataEntity, Long.MAX_VALUE, metadataV0.hash, """{"name":"PWNED"}""", bob, "meta-poison")
val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore)
assertEquals(0, floorsAfter[metadataEntity]?.version, "the floor must not follow a stray to the top of the version space")
// The owner tries to repair it, chaining honestly onto their own genesis.
val repair = edition(ControlEntityKind.METADATA, metadataEntity, 1, metadataV0.hash, """{"name":"My Community"}""", owner, "meta-1")
val pool = community + poison + repair
assertEquals(
"My Community",
ConcordCommunityState.fold(pool, owner).metadata?.name,
"a fresh joiner walks the chain and is unaffected",
)
assertEquals(
"My Community",
ConcordCommunityState.fold(pool, owner, floorsAfter).metadata?.name,
"a client holding a floor follows the honest chain, not the stray",
)
assertEquals(
"My Community",
ConcordCommunityState.fold(community + repair, owner, floorsAfter).metadata?.name,
"and a Refounding that drops the poison stays repaired",
)
}
@Test
fun oneEditionAtMaxVersionNoLongerDeletesAChannel() {
val channelV0 = edition(ControlEntityKind.CHANNEL, channelEntity, 0, null, """{"name":"general"}""", owner, "chan-0")
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_CHANNELS).toWire(), channelV0)
val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner)
val poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"general","deleted":true}""", bob, "chan-poison")
val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore)
val repair = edition(ControlEntityKind.CHANNEL, channelEntity, 1, channelV0.hash, """{"name":"general"}""", owner, "chan-1")
val pool = community + poison + repair
assertEquals(1, ConcordCommunityState.fold(pool, owner).channels.size, "a fresh joiner still sees the channel")
assertEquals(1, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "and so does a client holding a floor")
assertEquals(
1,
ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size,
"the channel survives a Refounding too",
)
}
@Test
fun aPoisonedBanlistStillAcceptsTheOwnersBan() {
// This was the saving grace before the fix — the reason the bug was "community with a broken
// name" rather than "community nobody can moderate". AuthorityResolver folds the banlist on
// its own floor-less chain walk and re-heals the union across every authorized edition, so
// the owner's ban landed even while the banlist's floor sat at Long.MAX_VALUE. The floor can
// no longer be poisoned, but keep this: do not "unify" the banlist onto the floored fold
// without replacing the protection.
val banlistV0 = edition(ControlEntityKind.BANLIST, banlistEntity, 0, null, "[]", owner, "ban-0")
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.BAN).toWire(), banlistV0)
val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner)
val poison = edition(ControlEntityKind.BANLIST, banlistEntity, Long.MAX_VALUE, banlistV0.hash, "[]", bob, "ban-poison")
val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore)
assertEquals(0, floorsAfter[banlistEntity]?.version, "the banlist floor is no longer poisonable either")
val ownerBansBob = edition(ControlEntityKind.BANLIST, banlistEntity, 1, banlistV0.hash, """["$bob"]""", owner, "ban-1")
val pool = community + poison + ownerBansBob
assertTrue(ConcordCommunityState.fold(pool, owner).authority.isBanned(bob), "a fresh joiner honors the ban")
assertTrue(
ConcordCommunityState.fold(pool, owner, floorsAfter).authority.isBanned(bob),
"the re-heal union must keep the banlist working even with a poisoned floor",
)
}
@Test
fun aGenuineCompactionJumpIsStillFollowed() {
// The tolerance the compaction arm exists for, pinned so the bound above cannot be tightened
// into breaking CORD-06 §3. After a Refounding the compacted head carries the `prev` it had
// before compaction, citing an edition in the PRIOR epoch that this client no longer holds —
// so it connects to nothing, and its version is legitimately several ahead of our floor.
val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0")
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0)
val floors = ConcordCommunityState.authorizedHeads(community, owner)
val danglingPrev = ByteArray(32) { 0x7f }
val compacted = edition(ControlEntityKind.METADATA, metadataEntity, 4, danglingPrev, """{"name":"Renamed While We Were Away"}""", owner, "meta-compacted")
assertEquals(
"Renamed While We Were Away",
ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + compacted, owner, floors).metadata?.name,
"a compacted head whose prev dangles by design must still be adopted",
)
}
@Test
fun aJumpBeyondTheCapIsRefusedAsAGap() {
// Same shape as the genuine compaction above, one version past the bound: not a compaction we
// missed, so the fold reports a gap and keeps what it already had rather than following it.
val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0")
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0)
val floors = ConcordCommunityState.authorizedHeads(community, owner)
val danglingPrev = ByteArray(32) { 0x7f }
val tooFar =
edition(
ControlEntityKind.METADATA,
metadataEntity,
EditionFold.MAX_COMPACTION_VERSION_JUMP + 1,
danglingPrev,
"""{"name":"PWNED"}""",
bob,
"meta-far",
)
assertEquals(
"My Community",
ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + tooFar, owner, floors).metadata?.name,
"a jump past the bound is a gap, not a head",
)
}
}
@@ -82,7 +82,7 @@ class ConcordStrandedRecoveryTest {
val prior = HeldRoot(0L, "aa".repeat(32))
val stranded = entry(epoch = 1, heldRoots = listOf(prior))
val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5))
val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false)
assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind")
// adopted the new epoch's access root
@@ -106,27 +106,27 @@ class ConcordStrandedRecoveryTest {
@Test
fun sameEpochBundleIsANoOp() {
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5)))
assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5)))
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false))
assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false))
}
@Test
fun lowerEpochBundleIsANoOp() {
// Epoch-monotonic: a stale bundle must never walk the membership backwards.
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3)))
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3), bannedAtCurrentEpoch = false))
}
@Test
fun entryWithoutInviteRefIsInert() {
// Direct invites and legacy entries have no anchor — expected, not an error.
val noAnchor = entry(epoch = 1, ref = null)
assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9)))
assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9)))
assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false))
assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false))
}
@Test
fun bundleForAnotherCommunityIsIgnored() {
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32))))
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)), bannedAtCurrentEpoch = false))
}
// ---- the bare `<naddr>#<fragment>` anchor form ----------------------------
@@ -252,4 +252,16 @@ class ConcordStrandedRecoveryTest {
assertEquals(4L, other.rootEpoch)
assertEquals(inviteRef, other.inviteRef)
}
@Test
fun aBannedMemberDoesNotRecoverIntoTheEpochTheyWereRemovedFrom() {
// The removal case the higher-epoch test cannot tell apart on its own: an ex-member keeps the
// link's unlock token forever, so without the ban gate the recovery sweep merges them into the
// very epoch a Refounding rotated them out of. See A2 in docs/concord-soft-ban-audit.md.
val stranded = entry(epoch = 1)
assertFalse(ConcordStrandedRecovery.isStranded(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true))
assertNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true))
// ...and the legitimate case still works, so the gate is not just "recovery off".
assertNotNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false))
}
}
@@ -74,6 +74,7 @@ class ConcordRefoundingTest {
recipientsXOnly = listOf(alice.pubKey, bob.pubKey),
staffXOnly = setOf(owner.pubKey),
createdAt = now,
ownerPubKey = owner.pubKey,
)
assertEquals(community.rootEpoch + 1, build.newEpoch)
@@ -113,6 +114,7 @@ class ConcordRefoundingTest {
recipientsXOnly = listOf(alice.pubKey),
staffXOnly = setOf(owner.pubKey),
createdAt = now,
ownerPubKey = owner.pubKey,
)
val newControl = build.newControlKeys
@@ -184,6 +186,7 @@ class ConcordRefoundingTest {
recipientsXOnly = listOf(alice.pubKey),
staffXOnly = setOf(owner.pubKey),
createdAt = now,
ownerPubKey = owner.pubKey,
)
val newControl = build.newControlKeys
@@ -223,6 +226,7 @@ class ConcordRefoundingTest {
recipientsXOnly = listOf(alice.pubKey),
staffXOnly = setOf(owner.pubKey),
createdAt = now,
ownerPubKey = owner.pubKey,
)
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch)
@@ -230,4 +234,76 @@ class ConcordRefoundingTest {
val wrongRoot = ByteArray(32) { 0x11 }
assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, community.communityId, wrongRoot, community.rootEpoch))
}
@Test
fun compactionRefusesAForgedGenesisAndCarriesTheAuthorizedHead() =
runTest {
// A compaction re-wraps ONE edition per entity and nothing downstream re-checks the
// choice, so how that edition is picked is a security decision, not a detail.
//
// Raw highest-version lets a stray at an arbitrary version through. But the bare
// structural chain walk is worse: with no floor it anchors at the lowest-version edition
// carrying no `prev`, and after a PRIOR compaction the real head's `prev` dangles into a
// trimmed epoch by design — so a forged `version = 1, prev = null` decoy outranks a
// genuine v50→v52 chain, and becomes the entity's entire carried-forward state. A forged
// empty banlist would erase every ban that way. Only the owner-rooted gate is safe.
val community = ConcordCommunityFactory.create(owner, "Test", now)
val communityId = community.communityId
val control = community.controlPlane
// The metadata entity, already compacted once: its head chains from an epoch we no longer hold.
val danglingPrev = ByteArray(32) { 0x7F }
val realHead =
ConcordStreamEnvelope.wrap(
ControlEditionBuilder.rumor(
owner.pubKey,
ControlEntityKind.METADATA,
communityId,
50,
danglingPrev,
ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "Real")),
now,
null,
),
control,
owner,
encrypted = false,
createdAt = now,
)
// carol holds nothing at all and mints a genesis-shaped decoy at version 1.
val forged =
ConcordStreamEnvelope.wrap(
ControlEditionBuilder.rumor(
carol.pubKey,
ControlEntityKind.METADATA,
communityId,
1,
null,
ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "PWNED")),
now,
null,
),
control,
carol,
encrypted = false,
createdAt = now,
)
val newEpoch = community.rootEpoch + 1
val newControl =
com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
.forStaff(newRoot, communityId, newEpoch, newControlRoot)
val compacted = ConcordRefounding.compactControlPlane(listOf(realHead, forged), control, newControl, owner.pubKey)
val carried =
compacted
.mapNotNull { ConcordStreamEnvelope.openOrNull(it, newControl) }
.mapNotNull { ControlEdition.fromRumor(it.rumor) }
.filter { it.entityKind == ControlEntityKind.METADATA }
assertEquals(1, carried.size, "one metadata edition carried forward")
assertEquals(50, carried.single().version, "the owner's real head, not the forged genesis")
assertEquals("Real", ConcordJson.decodeOrNull<MetadataEntity>(carried.single().content)?.name)
}
}
@@ -109,6 +109,7 @@ class ControlRootRotationTest {
recipientsXOnly = listOf(owner.pubKey, moderator.pubKey, member.pubKey),
staffXOnly = setOf(owner.pubKey, moderator.pubKey),
createdAt = now,
ownerPubKey = owner.pubKey,
)
val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch)
@@ -149,6 +150,7 @@ class ControlRootRotationTest {
recipientsXOnly = listOf(owner.pubKey, member.pubKey),
staffXOnly = setOf(owner.pubKey),
createdAt = now,
ownerPubKey = owner.pubKey,
)
// The rotator's own view writes; a member's view of the same epoch only reads.