docs(concord): audit the soft-ban and Control Plane attack surface

Collects the findings from this branch into docs/concord-soft-ban-audit.md,
each marked Verified (a test reproduces it, named) or Read (follows from the
code, untested), with a suggested order of attack.

Adds the reproduction for the one finding that was still unverified, and it did
not hold up the way it was first described. Version inflation does not poison
the anti-rollback floor through the chain walk — that walk advances only to
head.version + 1 citing the head's hash, so a fresh joiner is untouched. It goes
through the COMPACTION ARM: once a client holds a floor and the entity is in the
epoch snapshot, the head comes from bootstrapHead, which is highest-version at
or above the floor with no prev, no hash and no contiguity. Version is then the
whole contest and Long.MAX_VALUE wins it permanently — the floor rises to
MAX_VALUE, no honest edition can exceed it, and a Refounding that drops the
poison falls back to EntityFloor.known, which is the poison.

That makes it the worst item on the list: unrecoverable, and authored in the
tests by a current, legitimately granted moderator — no ban, no sockpuppet, one
ordinary permission bit. compactControlPlane picks per entity by raw max version
too, so honest rotators carry it into every future epoch.

The banlist escapes only because AuthorityResolver folds it on a floor-less
chain walk and re-heals the union, so an honest ban still lands. That accident
is all that separates this from a permanently unmoderatable community, so it is
pinned by its own test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
This commit is contained in:
Claude
2026-08-08 23:06:54 +00:00
parent 41a035034b
commit 1e6cda712d
2 changed files with 397 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
# Concord: soft-ban and Control Plane audit
**Scope:** what a removed member — or a moderator who turns — can still do to a Concord community,
assuming a **malicious client** (no client-side rule binds them; only cryptography, the fold, and
the relay do).
**Date:** 2026-08-08. **Status:** findings only, nothing fixed yet.
**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.
---
## Summary
| # | Finding | Severity | Needs a ban? | Recoverable? |
|---|---------|----------|--------------|--------------|
| [V1](#v1) | One edition at `version = Long.MAX_VALUE` pins an entity forever | **Critical** | No — any bit-holder | **No** |
| [V2](#v2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | Yes (Refounding) |
| [V3](#v3) | A rogue rotator compacts the banlist away | High | Via V2 | Partly |
| [V4](#v4) | The Refounding recipient set is attacker-inflatable | High | No | Yes |
| [V5](#v5) | The ban is a per-pubkey display rule; the channel key is not revoked | High | Yes | Yes (Refounding) |
| [V6](#v6) | Channel history is deletable on a naive third-party relay | High | Yes | **No** (history) |
| [V7](#v7) | Banlist rank rule diverges from Armada | Medium | — | — |
| [V8](#v8) | A soft ban revokes no read access and no live invite | Medium | Yes | Yes (Refounding) |
| [V9](#v9) | The base-rekey plane is writable by every member | Low | Yes | Yes |
The two structural causes worth naming up front, because most of the list collapses into them:
- **Authority is checked in two places that disagree.** `ConcordCommunityState.fold` gates
METADATA/CHANNEL/INVITE through `authority.hasPermission` (`!isBanned && …`), while ROLE, GRANT
and BANLIST are gated *inside* `AuthorityResolver.resolve` by `holdsManageRoles` / `bitsOf` /
`effectivePermissionsOf`, none of which consult the banlist. That is V2, and V3 follows from it.
- **A ban removes standing, never keys.** Everything a member holds — `community_root`, channel
keys, `control_root` if staff, live invite links — survives it. Only a CORD-06 Refounding rotates
those, which is why V4 (making Refounding expensive) is worth more to an attacker than it looks.
---
## <a name="v1"></a>V1 — One edition at `Long.MAX_VALUE` pins an entity forever
**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="v2"></a>V2 — A banned staffer keeps Role, Grant and Banlist authority
**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="v3"></a>V3 — A rogue rotator compacts the banlist away
**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 V2 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 V2 removes the puppet and takes this with it; floors alone do not, since they only
protect people who were already there.
## <a name="v4"></a>V4 — The Refounding recipient set is attacker-inflatable
**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="v5"></a>V5 — The ban is a per-pubkey display rule and the channel key is not revoked
**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 V4.
There is no client-side answer; only a Refounding rotates the key out from under them. That is the
correct design, which is why V4 matters so much.
## <a name="v6"></a>V6 — Channel history is deletable on a naive third-party relay
**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="v7"></a>V7 — 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 — V2 is the general form of it, and the fix proposed there
resolves both.
## <a name="v8"></a>V8 — A soft ban revokes no read access and no live invite
**Medium, inherent.** *Read:* CORD-02/05.
Until a Refounding, a banned member decrypts everything published — the ban only stops honest
clients from *showing* their posts, not from delivering the group's posts to them. They also keep
any invite links they created while privileged; those still resolve to bundles carrying the current
root. Publishing the root, or one live link, invites an unbanned crowd that each has to be banned
individually (and see V5).
Not a bug so much as the definition of a soft ban, but it belongs on the list because the UI should
say so: "Ban" and "Remove from community" are very different promises and users will read the first
as the second.
## <a name="v9"></a>V9 — The base-rekey plane is writable by every member
**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.
---
## Suggested order
1. **V4** — cheapest, not consensus-affecting, and it protects the remedy every other fix depends on.
2. **V1** — worst blast radius and the only unrecoverable one; does not need an attacker to be
banned or privileged beyond a single ordinary bit.
3. **V2** (+V3, +V7 row 3) — one two-pass change closes all three. Coordinate with Armada first;
this one splits consensus.
4. **V6** — spec note + relay guidance; our own behaviour is already correct and now pinned.
5. **V5 / V8** — UI honesty about what a ban does, and a "Remove from community" affordance that
Refounds rather than bans.
@@ -0,0 +1,167 @@
/*
* 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
/**
* **V1 in `docs/concord-soft-ban-audit.md` — reproduction.** A single Control Plane edition at
* `version = Long.MAX_VALUE` pins its entity to the author's content permanently, for every client
* that holds a floor for it.
*
* The chain walk is not the weakness — it advances only to `head.version + 1` citing the head's
* hash, so an inflated version is unreachable and a fresh joiner is unaffected. The weakness is the
* **compaction arm** of [EditionFold.foldEntity]: once a client holds a floor for an entity and that
* entity appears in the epoch snapshot (which `ConcordCommunityState.fold` always builds from the
* editions handed to it), the head is chosen by [EditionFold.bootstrapHead] — *highest version at or
* above the floor*, with no `prev`, no hash, and no contiguity. Version is then the whole contest,
* and `Long.MAX_VALUE` wins it forever:
*
* 1. the poison becomes the head, 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 either — nothing is offered at or above the
* floor, so the fold reports a gap and falls back to [EntityFloor.known], which *is* the poison.
*
* 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
* do this at any time, and demoting or banning them afterwards changes nothing, because the damage
* is already in every client's floor. It is also carried into every future epoch by
* `ConcordRefounding.compactControlPlane`, which selects the head per entity by raw highest version.
*
* The banlist is the one entity that survives, 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 lands even when the head is poisoned. [aPoisonedBanlistStillAcceptsTheOwnersBan] pins
* that, because it is 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 oneEditionAtMaxVersionPinsTheMetadataForever() {
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(Long.MAX_VALUE, floorsAfter[metadataEntity]?.version, "VULNERABLE: the floor is now at 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(
"PWNED",
ConcordCommunityState.fold(pool, owner, floorsAfter).metadata?.name,
"VULNERABLE: every client holding a floor is pinned to the attacker's content",
)
assertEquals(
"PWNED",
ConcordCommunityState.fold(community + repair, owner, floorsAfter).metadata?.name,
"VULNERABLE: even a Refounding that drops the poison falls back to it as EntityFloor.known",
)
}
@Test
fun oneEditionAtMaxVersionDeletesAChannelForever() {
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(0, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "VULNERABLE: the channel is gone and cannot be restored")
assertEquals(
0,
ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size,
"VULNERABLE: dropping the poison does not bring the channel back",
)
}
@Test
fun aPoisonedBanlistStillAcceptsTheOwnersBan() {
// The saving grace, and the reason this bug is "unmoderatable community" rather than
// "community with a broken name". 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 lands
// even while the banlist's own floor sits at Long.MAX_VALUE. Do not "unify" the banlist onto
// the floored fold without replacing this 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(Long.MAX_VALUE, floorsAfter[banlistEntity]?.version, "the banlist floor is poisoned like any other")
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",
)
}
}