From d8dba40e5bb606c9f3540105dfc8589841ecf17b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 30 Sep 2026 10:10:36 -0400 Subject: [PATCH] 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 --- .../model/concord/ConcordDirectInviteInbox.kt | 18 +++++++++++++----- .../concord/ConcordDirectInviteInboxTest.kt | 7 +++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt index 4464c2582d..1291c03a5b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt @@ -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() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt index 5203f380cf..c48a603ec2 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt @@ -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()) } }