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
@@ -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.