From f52a8b0432d3c4abd2ca10fa5b2b9fdfc51b5929 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 01:41:52 +0000 Subject: [PATCH] docs(concord): split the audit by what the attacker needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-reviewed every finding against the shipping app rather than against the protocol, and split the list in two: what a banned user can do with stock Amethyst (our bugs) versus what needs a hand-written client (fix in the fold, or defend against). Several items moved, and the review turned up a new one that belongs at the top. A1 is new and is the realistic attack. mintConcordInvite checks only that the account is writeable and that we hold the community — no CREATE_INVITE, no banlist — and unlike the Edit and channel buttons next to it, the invite IconButton carries no guard at all. A banned user stays in the app, taps person-add, and shares a working link to the community. The mint publishes a fresh link signer, so revoking the links they were given does not touch the ones they make; and because the bundle is a standalone kind-33301 outside the Control Plane, the CREATE_INVITE bit the fold enforces on INVITE_* entities never applies to the actual invite mechanism. A3 is the general form: every moderation verb checks isWriteable() and the Control write key and nothing else, so authority lives in the composable that draws the button — and those gates use effectivePermissions, which is ban-blind. Ban and Remove survive only because a second, unrelated condition routes through the ban-aware canActOn. refoundConcordCommunity guards itself with effectivePermissions outright, so a banned BAN-holder can launch a Refounding from the shipping app; honest receivers refuse it, but that is a race against banlist propagation, not a check. A2 moves to Part A because our own client is what performs it: the recovery sweep runs every 15 minutes with no banlist check. C2 (voice) is downgraded from High — ConcordBrokerToken and VoicePresence are referenced nowhere outside quartz, so there is no shipping path to attack. It is a note for whoever wires one up. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj --- docs/concord-soft-ban-audit.md | 347 ++++++++++++++++++++++----------- 1 file changed, 233 insertions(+), 114 deletions(-) diff --git a/docs/concord-soft-ban-audit.md b/docs/concord-soft-ban-audit.md index 8f284085ff..58429394ca 100644 --- a/docs/concord-soft-ban-audit.md +++ b/docs/concord-soft-ban-audit.md @@ -1,48 +1,205 @@ # 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. +**Scope:** what a removed member — or a moderator who turns — can still do to a Concord community. +**Date:** 2026-08-09. **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. +**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 -| # | 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 | -| [V10](#v10) | Stranded recovery: either broken, or a removal bypass | **Critical** | Yes | — | -| [V11](#v11) | Voice rooms are key-gated, not roster-gated | High | Yes | Yes (Refounding) | -| [V12](#v12) | Typing indicators are not ban-filtered | Low | Yes | Yes | +### Part A — reachable from stock Amethyst (our bugs) -The two structural causes worth naming up front, because most of the list collapses into them: +| # | Finding | Severity | Was | +|---|---------|----------|-----| +| [A1](#a1) | Any member — banned included — mints a working invite in one tap | **Critical** | new | +| [A2](#a2) | Stranded recovery runs on a timer and never checks the banlist | **Critical** | V10 | +| [A3](#a3) | The action layer has no permission checks; the UI's are ban-blind | High | new | +| [A4](#a4) | A banned member keeps broadcasting "typing", and we keep showing it | Low | V12 | +| [A5](#a5) | A banned member's own client keeps reading and rendering everything | Medium | V8 | -- **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. +### Part B — requires a malicious client + +| # | Finding | Severity | Needs a ban? | Recoverable? | Was | +|---|---------|----------|--------------|--------------|-----| +| [B1](#b1) | One edition at `version = Long.MAX_VALUE` pins an entity forever | **Critical** | No — any bit-holder | **No** | V1 | +| [B2](#b2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | Yes (Refounding) | V2 | +| [B3](#b3) | A rogue rotator compacts the banlist away | High | Via B2 | Partly | V3 | +| [B4](#b4) | The Refounding recipient set is attacker-inflatable | High | No | Yes | V4 | +| [B5](#b5) | The ban is per-pubkey; the channel key is not revoked | High | Yes | Yes (Refounding) | V5 | +| [B6](#b6) | Channel history is deletable on a naive third-party relay | High | Yes | **No** (history) | V6 | +| [B7](#b7) | The base-rekey plane is writable by every member | Low | Yes | Yes | V9 | + +### Part C — interop and not-yet-shipped + +| # | Finding | Severity | Was | +|---|---------|----------|-----| +| [C1](#c1) | Banlist rank rule diverges from Armada | Medium | V7 | +| [C2](#c2) | CORD-07 voice rooms are key-gated, not roster-gated | Design | V11 | --- -## V1 — One edition at `Long.MAX_VALUE` pins an entity forever +# Part A — reachable from stock Amethyst + +No custom tooling. A banned user with the shipping app, or our own background sweep. + +## A1 — Any member, banned included, mints a working invite in one tap + +**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. + +## A2 — Stranded recovery runs on a timer and never checks the banlist + +**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. + +## A3 — The action layer has no permission checks; the UI's are ban-blind + +**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. + +## A4 — A banned member keeps broadcasting "typing", and we keep showing it + +**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. + +## A5 — A banned member's own client keeps reading and rendering everything + +**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. + +## B1 — 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.** @@ -87,7 +244,7 @@ cross-epoch tolerance. Options, roughly in order of preference: The first is the smallest change and closes the unrecoverability; the third should happen regardless. -## V2 — A banned staffer keeps Role, Grant and Banlist authority +## B2 — A banned staffer keeps Role, Grant and Banlist authority **Critical.** *Verified:* `quartz/…/cord04Roles/BannedStaffEscalationTest.kt` (13 tests). @@ -118,22 +275,22 @@ banned in pass A; then recompute the banlist under pass B's roster, keeping only authorized. Deterministic, terminates, no oscillation on mutual bans. **Consensus-affecting**: until Armada ships the same rule, we will drop editions they honor. -## V3 — A rogue rotator compacts the banlist away +## B3 — 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. +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 V2 removes the puppet and takes this with it; floors alone do not, since they only +forged. Closing B2 removes the puppet and takes this with it; floors alone do not, since they only protect people who were already there. -## V4 — The Refounding recipient set is attacker-inflatable +## B4 — The Refounding recipient set is attacker-inflatable **High.** *Read:* `ConcordCommunitySession.allMembers()` / `emitChannelRumors`; `AccountConcordActions.refoundConcordCommunity` step 2; `ConcordRefounding.buildBaseRekeyWraps`. @@ -152,7 +309,7 @@ This is the cheapest thing on the list to fix and the only one that is not conse 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. -## V5 — The ban is a per-pubkey display rule and the channel key is not revoked +## B5 — The ban is a per-pubkey display rule and the channel key is not revoked **High.** *Read:* `Account.consumeConcordRumorGated` (`isBanned(rumor.pubKey)`), `Account.isAcceptable`. @@ -160,12 +317,12 @@ Writing to a channel needs the channel key, which the ban does not take away; th 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. +*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 V4 matters so much. +correct design, which is why B4 matters so much. -## V6 — Channel history is deletable on a naive third-party relay +## B6 — Channel history is deletable on a naive third-party relay **High, external.** *Verified (that we are safe):* `geode/…/ConcordPlaneKeyDeletionTest.kt` (3 tests). @@ -187,30 +344,7 @@ A community publishes wherever its metadata points. Any relay that authorizes de `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. -## 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. - -## 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. - -## V9 — The base-rekey plane is writable by every member +## B7 — The base-rekey plane is writable by every member **Low.** *Read:* `ConcordKeyDerivation.baseRekeyAddress`, `AccountConcordActions.drainConcordRekeys`. @@ -219,63 +353,36 @@ valid wraps there. Authorization happens after the blobs are scanned, so a flood a locator scan per blob on every revision tick. Bounded work per wrap and no correctness impact; listed for completeness. -## V10 — Stranded recovery: either broken, or a removal bypass -**Critical, and it forks — one of the two halves is true and both are bad.** -*Read:* `ConcordStrandedRecovery`, `AccountConcordActions.recoverStrandedConcordCommunities`, -`AccountConcordActions.mintConcordInvite`. +--- -`ConcordStrandedRecovery.isStranded` / `mergeForward` take only `(entry, bundle)`. There is **no -banlist check and no check that we were legitimately re-keyed** — the entire test is "the bundle at -my stored `inviteRef` sits at a higher epoch than I do". The unlock token lives in the link -fragment, which an ex-member keeps forever. So whether a removed member walks back in with the new -root depends *only* on whether the bundle at that coordinate ever advances an epoch. +# Part C — interop and not-yet-shipped -In Amethyst it never does: `mintConcordInvite` mints a **fresh link signer per mint**, so nothing -re-publishes at an existing coordinate, and `refoundConcordCommunity` does not re-mint or revoke -anything. Two consequences, and they are the fork: +## C1 — Banlist rank rule diverges from Armada -- **If nothing re-mints** — today's behaviour — then stranded recovery never fires *for anyone*. - That makes it 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 ("the - community keeps publishing its bundle at that same addressable coordinate, re-minted at the - current epoch"), so plausibly Armada in a cross-client community — then every removed member who - joined through a still-live link auto-recovers the new root on the 15-minute sweep, and - re-announces a Guestbook join so they look current again. **Refounding, the only hard removal, - is silently undone.** +**Medium, known, deliberate.** See `docs/concord-banlist-rank-conformance.md`, already reported. -Note also that `refoundConcordCommunity` never revokes the invite links the removed member created -or joined through, even though `ControlEntityKind.INVITE_REVOKED` exists and `classifyInvite` -already honors it. +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. -**Fix direction.** Decide the intended semantics first — this needs a spec answer, not a patch. -Then: gate `mergeForward` on not being banned in the epoch we are merging *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 a different route. +## C2 — Voice rooms are key-gated, not roster-gated -## V11 — Voice rooms are key-gated, not roster-gated +**Design-level; not currently reachable.** *Read:* `ConcordBrokerToken`, CORD-07 §2. -**High.** *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. -This is the one place where a ban fails *audibly*, in real time, in front of everyone. Worth ranking -above its technical severity for that reason alone. - -## V12 — Typing indicators are not ban-filtered - -**Low.** *Read:* `ConcordCommunitySession.ingestTyping`. - -`ingestTyping` checks the rumor is a typing heartbeat, is bound to the channel/epoch, and is not our -own — and nothing else. A banned member (or any fresh npub holding the channel key, see V5) shows -in the "… is typing" row indefinitely. Cheap to fix and user-visible: the promise a ban makes is -that the member disappears, and here they do not. --- @@ -284,7 +391,7 @@ that the member disappears, and here they do not. 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 (V2). +self-unban (B2). Not looked at at all: @@ -292,7 +399,7 @@ Not looked at at all: 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 V4 makes expensive. + 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. @@ -301,11 +408,23 @@ Not looked at at all: ## 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. +**Part A first.** It is the whole of the realistic threat — a banned user with the app already +installed — and none of it needs coordination with anyone. + +1. **A1** — one guard on `mintConcordInvite` plus one on its button. Smallest fix on the list and it + closes the attack a banned user will actually reach for. +2. **A3** — move authority into the action layer and replace `effectivePermissions` with + `hasPermission` everywhere it is used as an authorization test. This is also the cheapest partial + mitigation for B2: it shrinks what a banned staffer can do *without* writing their own client. +3. **A2** — needs the semantics decided before any code. Raise it with the spec. +4. **A4 / A5** — small, user-visible, and they make the product honest about what a ban is. + +**Then Part B**, hardest first because the ceiling is highest: + +5. **B4** — cheap, not consensus-affecting, and it protects the remedy every other fix depends on. +6. **B1** — worst blast radius, the only unrecoverable one, and the bar is a single ordinary + permission bit. +7. **B2 (+B3, +C1's open row)** — one two-pass change closes all three. Coordinate with Armada + first; this one splits consensus. +8. **B6** — spec note plus relay-selection guidance; our own behaviour is already correct and pinned. +9. **B5 / B7** — accept, or bound.