fix(concord): only the community's staff can readmit a banned member

A readmission (ff7716f6a6) moved a banned member's held base to any
Direct Invite with a newer root epoch, and never checked the sender. A
bundle's community_root is not bound to its id, so anyone could mint a
"newer epoch" under a root of their own. One tap on Accept would then
replace the held community with theirs, and a huge epoch would block
every genuine readmission after it.

A readmission now needs what a catch-up needs: a seal-verified sender
who is staff (and not banned) in the held fold, for the same owner. The
test covers the forged case in both acceptPlan and visible().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-30 10:10:36 -04:00
co-authored by Claude Opus 5.5
parent e7b8d59a7e
commit d8dba40e5b
2 changed files with 20 additions and 5 deletions
@@ -349,7 +349,7 @@ class ConcordDirectInviteInbox(
): DirectInviteAcceptPlan {
if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired
if (held == null) return DirectInviteAcceptPlan.Join
if (isReadmission(held, heldState, opened.invite, me)) return DirectInviteAcceptPlan.Readmit
if (isReadmission(held, heldState, opened, me)) return DirectInviteAcceptPlan.Readmit
if (ConcordInviteVend.catchUpChannelIds(held, opened.invite).isEmpty()) return DirectInviteAcceptPlan.NothingNew
if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded
// Death wins every race (CORD-02 §9): a dissolved community takes no new keys.
@@ -369,18 +369,26 @@ class ConcordDirectInviteInbox(
* could never be accepted. A banned member has no live membership a bundle could hijack
* (the reason a bundle may never move a held base, CORD-06 §2), and the join re-checks the
* ban against the NEW epoch's roster, failing closed.
*
* A bundle's `community_root` is not bound to its id, so anyone could mint a "newer epoch"
* under a root of their own and, on one tap, replace the held base (and, with a huge epoch,
* block every genuine readmission after it). So, as for a catch-up, only a sender who is
* staff in the held fold (seal-verified) may readmit us, into the same owner's community.
*/
fun isReadmission(
held: ConcordCommunityListEntry,
heldState: ConcordCommunityState?,
invite: CommunityInvite,
opened: OpenedDirectInvite,
me: HexKey,
): Boolean =
heldState != null &&
!heldState.dissolved &&
heldState.authority.isBanned(me) &&
invite.communityId.equals(held.id, ignoreCase = true) &&
invite.rootEpoch > held.rootEpoch
opened.invite.communityId.equals(held.id, ignoreCase = true) &&
opened.invite.owner.equals(held.owner, ignoreCase = true) &&
opened.invite.rootEpoch > held.rootEpoch &&
heldState.authority.isStaff(opened.sender) &&
!heldState.authority.isBanned(opened.sender)
/**
* What a UI shows out of [pending], given the communities this account already holds
@@ -424,7 +432,7 @@ class ConcordDirectInviteInbox(
// a catch-up from a non-staff sender, for channels the fold doesn't know as Private,
// or into a dissolved community is refused by [acceptPlan], so it is not offered.
val heldState = held?.let { heldStateOf(it.id) }
val readmit = held != null && me != null && isReadmission(held, heldState, opened.invite, me)
val readmit = held != null && me != null && isReadmission(held, heldState, opened, me)
val newChannels =
when {
held == null || readmit -> emptyList()
@@ -503,5 +503,12 @@ class ConcordDirectInviteInboxTest {
// Dissolved: death wins.
assertNotEquals(DirectInviteAcceptPlan.Readmit, ConcordDirectInviteInbox.acceptPlan(reInvite, held, banned.withDissolved(true), me.pubKey))
// A bundle's root is not bound to the community id, so anyone can mint a "newer epoch"
// under their own root. Only staff of the held community may readmit us: a stranger's
// forgery must not move the held base, nor even be offered.
val forged = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c, root = "66".repeat(32), epoch = c.rootEpoch + 50)), me))
assertNotEquals(DirectInviteAcceptPlan.Readmit, ConcordDirectInviteInbox.acceptPlan(forged, held, banned, me.pubKey))
assertTrue(ConcordDirectInviteInbox.visible(listOf(forged), listOf(held), heldStateOf = { banned }, me = me.pubKey).isEmpty())
}
}