docs(concord): split the audit by what the attacker needs

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
This commit is contained in:
Claude
2026-08-09 01:41:52 +00:00
parent d37e183a57
commit f52a8b0432
+233 -114
View File
@@ -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 |
### <a name="part-a"></a>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.
### <a name="part-b"></a>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 |
### <a name="part-c"></a>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 |
---
## <a name="v1"></a>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.
## <a name="a1"></a>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.
## <a name="a2"></a>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.
## <a name="a3"></a>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.
## <a name="a4"></a>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.
## <a name="a5"></a>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.
## <a name="b1"></a>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.
## <a name="v2"></a>V2 — A banned staffer keeps Role, Grant and Banlist authority
## <a name="b2"></a>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.
## <a name="v3"></a>V3 — A rogue rotator compacts the banlist away
## <a name="b3"></a>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.
## <a name="v4"></a>V4 — The Refounding recipient set is attacker-inflatable
## <a name="b4"></a>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.
## <a name="v5"></a>V5 — The ban is a per-pubkey display rule and the channel key is not revoked
## <a name="b5"></a>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.
## <a name="v6"></a>V6 — Channel history is deletable on a naive third-party relay
## <a name="b6"></a>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.
## <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
## <a name="b7"></a>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.
## <a name="v10"></a>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:
## <a name="c1"></a>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.
## <a name="c2"></a>C2 — Voice rooms are key-gated, not roster-gated
## <a name="v11"></a>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.
## <a name="v12"></a>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.