mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
Merge pull request #3885 from vitorpamplona/claude/concord-soft-ban-vulnerability-rbjjet
fix(concord): close the soft-ban authority holes (audit A1–A4, B1, B2, B4)
This commit is contained in:
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
@@ -76,6 +77,15 @@ private const val CONCORD_ADMIN_ROLE = "Admin"
|
||||
*/
|
||||
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`.
|
||||
*
|
||||
* 120 blobs ride in each kind-3303 chunk, so this is ~42 published events and ~5k NIP-44
|
||||
* encryptions at the ceiling — heavy but survivable on a phone, and far above any real community.
|
||||
* Raising it raises the cost of the attack it exists to bound, not the safety.
|
||||
*/
|
||||
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
|
||||
|
||||
/**
|
||||
* Concord (encrypted communities) orchestration for an [Account]: join/create/
|
||||
* invite flows, channel messages/reactions/edits/typing, roles and moderation,
|
||||
@@ -171,6 +181,21 @@ class AccountConcordActions(
|
||||
val entry =
|
||||
account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id == communityId } ?: return null
|
||||
// CREATE_INVITE, and not while banned. This used to check only that we held the community,
|
||||
// which made minting the one moderation-free action in the app: a member the owner had just
|
||||
// banned could tap the invite button and hand out a working link to the community they were
|
||||
// removed from, and every account they invited arrived as a fresh un-banned npub.
|
||||
//
|
||||
// Note the bit is not otherwise enforced anywhere. The fold gates the INVITE_* Control
|
||||
// entities on CREATE_INVITE, but a link's bundle is a standalone kind-33301 published
|
||||
// OUTSIDE the Control Plane, so no fold ever sees it. This check is the only one there is.
|
||||
// The owner is proven by the community id (CORD-02), so they are read off the entry and can
|
||||
// mint before the session exists — the session is built asynchronously off the joined list,
|
||||
// and requiring it here would have made the owner's own invite button fail on a cold start.
|
||||
// Everyone else needs the folded roster, so no session means no invite.
|
||||
val session = account.concordSessions.sessionFor(communityId)
|
||||
val amOwner = entry.owner.equals(account.signer.pubKey, ignoreCase = true)
|
||||
if (!amOwner && (session == null || !isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE))) return null
|
||||
val invite =
|
||||
ConcordActions.inviteFor(
|
||||
communityIdHex = entry.id,
|
||||
@@ -430,7 +455,17 @@ class AccountConcordActions(
|
||||
channelIdHex: String,
|
||||
) {
|
||||
if (!account.isWriteable()) return
|
||||
val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return
|
||||
// A ban hides every message we send, so continuing to announce that we are typing them is
|
||||
// both noise and a contradiction of what the ban told the room. Filtered on the receive side
|
||||
// too (ConcordCommunitySession.ingestTyping) — a malicious client would keep sending.
|
||||
if (session.state.value
|
||||
?.authority
|
||||
?.isBanned(account.signer.pubKey) == true
|
||||
) {
|
||||
return
|
||||
}
|
||||
val entry = session.entry
|
||||
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now())
|
||||
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
@@ -487,6 +522,49 @@ class AccountConcordActions(
|
||||
return cp
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account may take the action guarded by [bit] in [session] — and, when [target] is
|
||||
* given, take it *against that member* (CORD-04 §3's rank rule, "equal cannot act on equal").
|
||||
*
|
||||
* Every moderation verb below funnels through this. It used to live only in the composables that
|
||||
* drew the buttons, which failed three ways: the screens tested `effectivePermissions`, which
|
||||
* ignores the banlist, so a banned staffer still saw the controls; a verb reached from anywhere
|
||||
* else (desktop, `amy`, a new screen) inherited no check at all; and holding `control_root` —
|
||||
* a spam gate, never authority (CORD-02 §5) — was the only thing actually being enforced.
|
||||
*
|
||||
* Fails **closed**, with one deliberate exception: the owner is read from [ConcordCommunityListEntry]
|
||||
* rather than from the fold, because the community id proves them (CORD-02) and they must stay able
|
||||
* to moderate before their Control Plane has finished folding — or through a fold a rogue has
|
||||
* damaged. Everyone else needs a resolved roster, so an unfolded community grants nobody else
|
||||
* anything.
|
||||
*/
|
||||
private fun isAuthorizedFor(
|
||||
session: ConcordCommunitySession,
|
||||
bit: Int,
|
||||
target: HexKey? = null,
|
||||
): Boolean {
|
||||
val me = account.signer.pubKey
|
||||
if (session.entry.owner.equals(me, ignoreCase = true)) return true
|
||||
val authority = session.state.value?.authority ?: return false
|
||||
// hasPermission, never effectivePermissions: the latter reads the roles alone and would let a
|
||||
// banned staffer keep acting for as long as they hold the key.
|
||||
val allowed = if (target == null) authority.hasPermission(me, bit) else authority.canActOn(me, target, bit)
|
||||
if (!allowed) {
|
||||
Log.w("Concord") { "Refusing a Concord action in ${session.entry.id}: not authorized for bit $bit${target?.let { " on $it" } ?: ""} (CORD-04 §3)" }
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
/** [controlKeysForWrite] gated by [isAuthorizedFor] — the standing check and the key check together. */
|
||||
private fun controlKeysForAction(
|
||||
session: ConcordCommunitySession,
|
||||
bit: Int,
|
||||
target: HexKey? = null,
|
||||
): ControlPlaneKeys? {
|
||||
if (!isAuthorizedFor(session, bit, target)) return null
|
||||
return controlKeysForWrite(session)
|
||||
}
|
||||
|
||||
/** Grant [member] exactly [roleIds] (empty list revokes their roles). */
|
||||
suspend fun grantConcordRole(
|
||||
communityId: String,
|
||||
@@ -495,7 +573,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
// A Grant that first makes its member staff must deliver the control_root in the same
|
||||
// edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the
|
||||
// roles carry a Control-writing bit and we hold the secret to hand over.
|
||||
@@ -567,7 +645,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
|
||||
val existing =
|
||||
session.state.value
|
||||
@@ -609,7 +687,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
return true
|
||||
@@ -657,7 +735,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
|
||||
val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
@@ -670,7 +748,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
|
||||
val wrap = ConcordModeration.unban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
@@ -701,10 +779,22 @@ class AccountConcordActions(
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
val state = session.state.value ?: return false
|
||||
val authority = state.authority
|
||||
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.BAN)
|
||||
// hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol
|
||||
// and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the
|
||||
// shipping app. Honest receivers refuse such a rotation (drainConcordRekeys checks the same
|
||||
// ban-aware predicate), but that is a race against banlist propagation, not a check.
|
||||
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.hasPermission(account.signer.pubKey, ConcordPermissions.BAN)
|
||||
if (!iCanBan) return false
|
||||
val removedLower = removed.mapTo(HashSet()) { it.lowercase() }
|
||||
if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false
|
||||
// Removal is the hardest form of a ban, so it takes the same rank rule (CORD-04 §3): an admin
|
||||
// cannot Refound a peer admin out of the community any more than they could ban one. The owner
|
||||
// short-circuits, as everywhere else, because canActOn starts at hasPermission.
|
||||
if (!authority.isOwner(account.signer.pubKey) &&
|
||||
removedLower.any { !authority.canActOn(account.signer.pubKey, it, ConcordPermissions.BAN) }
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// A Refounding writes the current plane (the pre-rotation bans) and the new one (the
|
||||
// compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A
|
||||
// rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery.
|
||||
@@ -735,7 +825,7 @@ class AccountConcordActions(
|
||||
.apply {
|
||||
removeAll(removedLower)
|
||||
removeAll(authority.bannedMembers())
|
||||
}.toList()
|
||||
}.let { candidates -> boundRecipients(candidates, authority) }
|
||||
|
||||
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
|
||||
val entry = session.entry
|
||||
@@ -762,6 +852,7 @@ class AccountConcordActions(
|
||||
recipientsXOnly = recipients,
|
||||
staffXOnly = staff,
|
||||
createdAt = TimeUtils.now(),
|
||||
ownerPubKey = entry.owner,
|
||||
)
|
||||
|
||||
// 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs
|
||||
@@ -778,6 +869,49 @@ class AccountConcordActions(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps the Refounding recipient set, keeping the members whose standing we can actually vouch
|
||||
* for when there are too many.
|
||||
*
|
||||
* `allMembers()` is the Guestbook ∪ `observedAuthors` ∪ the roster, and the first two are
|
||||
* unbounded and attacker-writable: a Guestbook Join is self-signed by any key at all, and every
|
||||
* author we decrypt is folded in by design (CORD-02 §5, "observably present"). So each throwaway
|
||||
* npub someone posts from, or simply announces, becomes one more mandatory blob in the next
|
||||
* Refounding — meaning the attack inflates the cost of its own remedy, and the remedy is the only
|
||||
* hard removal Concord has. See B4 in `docs/concord-soft-ban-audit.md`.
|
||||
*
|
||||
* The roster and the owner are kept unconditionally: they are owner-rooted, so they cannot be
|
||||
* padded from outside. The remainder fills the budget, and anything dropped is **logged rather
|
||||
* than silently truncated** — a dropped member is stranded on the dead epoch and their only way
|
||||
* back is a recovery path that needs to know it happened.
|
||||
*/
|
||||
private fun boundRecipients(
|
||||
candidates: Set<HexKey>,
|
||||
authority: AuthorityResolver,
|
||||
): List<HexKey> {
|
||||
if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList()
|
||||
|
||||
// The roster goes in whole even if it alone exceeds the budget: it is owner-rooted, so it
|
||||
// cannot be padded from outside, and dropping an admin to make room for a stranger inverts
|
||||
// the point of the cap.
|
||||
val vouched = authority.roleHolders() + authority.staffMembers()
|
||||
val kept = LinkedHashSet<HexKey>()
|
||||
candidates.filterTo(kept) { it in vouched }
|
||||
for (candidate in candidates) {
|
||||
if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break
|
||||
kept.add(candidate)
|
||||
}
|
||||
val dropped = candidates.size - kept.size
|
||||
if (dropped > 0) {
|
||||
Log.w("Concord") {
|
||||
"Refounding recipient set trimmed to ${kept.size} of ${candidates.size} " +
|
||||
"(budget $MAX_REFOUNDING_RECIPIENTS, roster kept whole): $dropped member(s) will be " +
|
||||
"stranded on the prior epoch"
|
||||
}
|
||||
}
|
||||
return kept.toList()
|
||||
}
|
||||
|
||||
// Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered
|
||||
// in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not
|
||||
// adopted — and re-published — twice on successive revision ticks.
|
||||
@@ -986,7 +1120,29 @@ class AccountConcordActions(
|
||||
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
|
||||
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
|
||||
// A removed member holds the link's unlock token forever, so without this the sweep
|
||||
// walks them straight back into the epoch they were rotated out of — see A2 in
|
||||
// docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last
|
||||
// one whose Control Plane we can still fold.
|
||||
//
|
||||
// Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not
|
||||
// exist yet or whose first fold has not landed, and this sweep runs on the revision tick
|
||||
// — so a banned member's own client would have hit that window on cold start and
|
||||
// recovered itself, which is precisely the bypass this gate exists to stop. No verdict
|
||||
// means no recovery; the next sweep retries once the roster is known.
|
||||
val authority =
|
||||
account.concordSessions
|
||||
.sessionFor(entry.id)
|
||||
?.state
|
||||
?.value
|
||||
?.authority
|
||||
if (authority == null) {
|
||||
Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" }
|
||||
lastConcordRecoveryCheck.remove(entry.id)
|
||||
continue
|
||||
}
|
||||
val bannedHere = authority.isBanned(account.signer.pubKey)
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue
|
||||
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
|
||||
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
|
||||
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged))
|
||||
@@ -1009,7 +1165,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false
|
||||
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
|
||||
val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
@@ -1027,7 +1183,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
val channelId = RandomInstance.bytes(32)
|
||||
val channel = ChannelEntity(name = name.trim())
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
@@ -1043,7 +1199,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
// Carry the standing definition forward and change only the name. A ChannelEntity built from
|
||||
// scratch defaults `private` and `voice` to false, so renaming a private channel used to
|
||||
// publish an edition declaring it PUBLIC — and a voice channel became a text channel.
|
||||
@@ -1066,7 +1222,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
// Same as rename: preserve the standing flags so a tombstone does not also silently
|
||||
// reclassify the channel it retires.
|
||||
val standing =
|
||||
|
||||
+32
-17
@@ -170,10 +170,14 @@ fun ConcordChannelListScreen(
|
||||
// Rank alone isn't enough on a split epoch: publishing any Control edition also takes the
|
||||
// control_root (CORD-02 §2), which a freshly promoted staffer may not hold yet (CORD-04 §3),
|
||||
// so the affordance waits for the key too.
|
||||
// hasPermission, never effectivePermissions: the latter reads the roles alone, so a banned
|
||||
// moderator kept seeing every control here. The editions they authored were dropped by everyone's
|
||||
// fold, which made these buttons silently no-op — worse than absent, and the same trap this file
|
||||
// already avoids for the Roles… menu.
|
||||
val canManageChannels =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS)
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_CHANNELS)
|
||||
} == true &&
|
||||
session?.controlPlaneKeys()?.canWrite == true
|
||||
|
||||
@@ -242,10 +246,19 @@ fun ConcordChannelListScreen(
|
||||
val canEdit =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA)
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_METADATA)
|
||||
} == true &&
|
||||
session?.controlPlaneKeys()?.canWrite == true
|
||||
|
||||
// Minting an invite hands out a working key to the community, so it takes
|
||||
// CREATE_INVITE like any other privileged action. This button used to be the one
|
||||
// control on the screen with no gate at all.
|
||||
val canInvite =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.CREATE_INVITE)
|
||||
} == true
|
||||
|
||||
IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) {
|
||||
SymbolIcon(symbol = MaterialSymbols.Group, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_members_title))
|
||||
}
|
||||
@@ -254,22 +267,24 @@ fun ConcordChannelListScreen(
|
||||
SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title))
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
enabled = !minting,
|
||||
onClick = {
|
||||
minting = true
|
||||
scope.launch {
|
||||
try {
|
||||
inviteLink = account.concord.mintConcordInvite(communityId)
|
||||
} finally {
|
||||
// Always clear the flag — a thrown mint would otherwise leave the
|
||||
// button disabled until the screen is recreated.
|
||||
minting = false
|
||||
if (canInvite) {
|
||||
IconButton(
|
||||
enabled = !minting,
|
||||
onClick = {
|
||||
minting = true
|
||||
scope.launch {
|
||||
try {
|
||||
inviteLink = account.concord.mintConcordInvite(communityId)
|
||||
} finally {
|
||||
// Always clear the flag — a thrown mint would otherwise leave the
|
||||
// button disabled until the screen is recreated.
|
||||
minting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
|
||||
},
|
||||
) {
|
||||
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow, mirroring the NIP-29 relay-group top bar: destructive membership
|
||||
|
||||
+4
-1
@@ -133,7 +133,10 @@ fun ConcordMembersScreen(
|
||||
}
|
||||
|
||||
val iAmOwner = state?.authority?.isOwner(myPubKey) == true
|
||||
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true
|
||||
// hasPermission, never effectivePermissions: a banned BAN-holder used to keep the whole Ban /
|
||||
// Remove menu. It only stayed harmless because `canBanTarget` below routes through canActOn,
|
||||
// which IS ban-aware — a thin margin for the escalation in docs/concord-soft-ban-audit.md.
|
||||
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.BAN) } == true
|
||||
val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true
|
||||
|
||||
// The roles this viewer may actually hand out. The fold drops a grant whose granter does
|
||||
|
||||
+4
-1
@@ -454,7 +454,8 @@ object ConcordActions {
|
||||
fun recoverStranded(
|
||||
entry: ConcordCommunityListEntry,
|
||||
bundle: CommunityInvite,
|
||||
): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle)
|
||||
bannedAtCurrentEpoch: Boolean,
|
||||
): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle, bannedAtCurrentEpoch)
|
||||
|
||||
/** Decrypts + validates a fetched bundle event with the link token; null if invalid. */
|
||||
fun openBundle(
|
||||
@@ -544,6 +545,7 @@ object ConcordActions {
|
||||
recipientsXOnly: List<HexKey>,
|
||||
staffXOnly: Set<HexKey>,
|
||||
createdAt: Long,
|
||||
ownerPubKey: HexKey,
|
||||
): RefoundingBuild =
|
||||
ConcordRefounding.build(
|
||||
rotatorSigner = rotatorSigner,
|
||||
@@ -557,6 +559,7 @@ object ConcordActions {
|
||||
recipientsXOnly = recipientsXOnly,
|
||||
staffXOnly = staffXOnly,
|
||||
createdAt = createdAt,
|
||||
ownerPubKey = ownerPubKey,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+3
@@ -486,6 +486,9 @@ class ConcordCommunitySession(
|
||||
if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, epoch)) return
|
||||
val who = rumor.pubKey.lowercase()
|
||||
if (who == myPubKey.lowercase()) return // never show my own typing back to me
|
||||
// A banned member's messages are dropped everywhere, so their typing heartbeat must be too —
|
||||
// otherwise they sit in the "… is typing" row forever in a channel they cannot be heard in.
|
||||
if (_state.value?.authority?.isBanned(who) == true) return
|
||||
val now = TimeUtils.now()
|
||||
// Update the map and publish inside the lock so a concurrent heartbeat on another
|
||||
// channel can't publish an older snapshot last and drop this channel's typers.
|
||||
|
||||
+1
@@ -123,6 +123,7 @@ class ConcordActionsTest {
|
||||
// Only the owner is staff, so only the owner's blob carries the secret.
|
||||
staffXOnly = setOf(owner.pubKey),
|
||||
createdAt = 5L,
|
||||
ownerPubKey = owner.pubKey,
|
||||
)
|
||||
|
||||
val baseRekey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch)
|
||||
|
||||
+3
-3
@@ -78,7 +78,7 @@ class ConcordRollbackFloorTest {
|
||||
// new epoch's plane is split and addressed by the derived signer, not the root.
|
||||
val newControlRoot = ByteArray(32) { 0x44 }
|
||||
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
|
||||
val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl)
|
||||
val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey)
|
||||
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
@@ -145,7 +145,7 @@ class ConcordRollbackFloorTest {
|
||||
val newControlRoot = ByteArray(32) { 0x44 }
|
||||
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
|
||||
// Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives.
|
||||
val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl)
|
||||
val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl, community.ownerPubKey)
|
||||
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
@@ -186,7 +186,7 @@ class ConcordRollbackFloorTest {
|
||||
// new epoch's plane is split and addressed by the derived signer, not the root.
|
||||
val newControlRoot = ByteArray(32) { 0x44 }
|
||||
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
|
||||
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl)
|
||||
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey)
|
||||
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Concord: the Banlist is not rank-gated in any implementation (CORD-04 conformance)
|
||||
|
||||
**Status:** conformance bug. Reproduced in Amethyst and **fixed there** (see §6); present by
|
||||
inspection in Armada.
|
||||
inspection in Armada. Finding #3, left open in §4 as a fixpoint-ordering question, is now also
|
||||
implemented in Amethyst — see the 2026-08-09 update before §Rollout status.
|
||||
**Severity:** privilege escalation. Any `BAN` holder can neutralise every authority above them,
|
||||
including the owner.
|
||||
**Reported by:** Amethyst (MIT), 2026-07-20. Findings verified by unit test; see "Evidence" below.
|
||||
@@ -190,6 +191,61 @@ We'd also suggest **§4 restating the rank half inline**, the way §2 does for G
|
||||
does for Kicks. Both independent implementations read §4 in isolation and both got it wrong the same
|
||||
way; that is strong evidence the section is the problem, not the readers.
|
||||
|
||||
### Update, 2026-08-09: we have now implemented #3
|
||||
|
||||
Amethyst now answers the ordering question rather than leaving it open, because #3 turned out to be
|
||||
the doorway to a full community takeover and not merely an inconsistency — a banned staffer who kept
|
||||
`control_root` kept the entire roster, and could mint a fresh un-banned npub that passed every
|
||||
ban-aware gate. The write-up is `docs/concord-soft-ban-audit.md` (B2).
|
||||
|
||||
The rule we shipped: **authority only ever shrinks, over two passes.** Pass A resolves exactly as
|
||||
before and yields a candidate banlist; pass B re-resolves with every author on that list treated as
|
||||
holding no authority at all, for roles, grants and the Banlist alike. Two passes, always, so it
|
||||
terminates by construction. It cannot oscillate on mutual bans either, because the rank rule makes
|
||||
them unreachable: only a member who strictly outranks you may ban you, and you cannot outrank them
|
||||
back.
|
||||
|
||||
Note what it costs, because it is not obvious and you would hit it too: this **cascades**. Every
|
||||
edition a banned member ever authored is dropped, grants included, so banning an admin also demotes
|
||||
everyone that admin promoted. We think that is the literal reading of §4 and it is what kills the
|
||||
sockpuppet — but a legitimate promotion by a later-banned admin vanishes with it, and the owner has
|
||||
to re-issue it. If you read §4 as scoping only to editions authored *after* the ban, say so; that is
|
||||
implementable too, but it needs the spec to define an ordering between an edition and a Banlist
|
||||
entry, which today it does not.
|
||||
|
||||
We checked your implementation before writing this, and you got there first: `foldControlState`
|
||||
already runs the same two-pass, with the same §4 justification in the comment. So this is us catching
|
||||
up, not diverging — with one narrower difference. You keep **pass 1's** Banlist as the final word;
|
||||
we recompute it in pass 2. So a banned admin's mass-ban of everyone beneath them still stands for you
|
||||
and is dropped by us. Your stated reason is to stop the anti-roster erasing itself; ours is that an
|
||||
edition should not outlive its author's removal, and the self-erasure case is unreachable under the
|
||||
rank rule anyway, since only a member who strictly outranks you can ban you. We would rather converge
|
||||
than be right — tell us which way and we will move.
|
||||
|
||||
Two more things that fell out of reading `src/concord-v2/` side by side, both worth their own look:
|
||||
|
||||
- **`bootstrapHead` has no bound** (`lib/version.ts`), and `headCandidates` uses it for the
|
||||
compaction arm while `pickHead` then raises the stored floor to whatever won. One authorized
|
||||
edition at `version = 2^63 - 1` therefore becomes an entity's permanent head: the floor rises to
|
||||
match, nothing honest can exceed it, and even a Refounding that drops the edition falls back to the
|
||||
remembered head — which is that edition. It needs no ban and no sockpuppet, just one ordinary
|
||||
permission bit. This is the second bug both implementations share by reading the same section the
|
||||
same way; ours is described in `docs/concord-soft-ban-audit.md` (B1), and we bounded the jump the
|
||||
arm will follow.
|
||||
- **Your Banlist takes only the gated head's content**, with no §4 re-heal union. We union in every
|
||||
authorized non-ancestor edition, which is what defeats an attempt to launder a ban away by forking
|
||||
the list at genesis. So we honor concurrent bans you drop. Which is normative?
|
||||
|
||||
A chain-local rule is not enough, and this is the trap worth flagging: "the author must not be banned
|
||||
by the state their edition chains from" is bypassed by forking the Banlist at genesis, where no
|
||||
parent ever mentions the ban and §4's re-heal union carries it in anyway. The rule has to bind the
|
||||
union, which is why ours is a whole-pass mask rather than a per-edition check.
|
||||
|
||||
This widens the divergence in §6: we now drop editions you honor in any community where a privileged
|
||||
member was banned, not only where a signer failed to outrank their target.
|
||||
|
||||
---
|
||||
|
||||
Separately, please rule on **#3**: whether a banned npub's Banlist edition is honored. Our reading of
|
||||
§4 ("drops every event from a banned npub — message, reaction, edit, or authority action") is that it
|
||||
must not be, but the fixpoint ordering needs to be stated for that to be implementable consistently.
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
# Concord: soft-ban and Control Plane audit
|
||||
|
||||
**Scope:** what a removed member — or a moderator who turns — can still do to a Concord community.
|
||||
**Date:** 2026-08-09. **Status:** A1–A4, B1, B2 and B4 are **fixed** on this branch; the rest are
|
||||
accepted, deferred to the spec, or belong to other people's relays. Each section carries its own
|
||||
status line.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
### <a name="part-a"></a>Part A — reachable from stock Amethyst (our bugs)
|
||||
|
||||
| # | Finding | Severity | Status |
|
||||
|---|---------|----------|--------|
|
||||
| [A1](#a1) | Any member — banned included — mints a working invite in one tap | **Critical** | **Fixed** |
|
||||
| [A2](#a2) | Stranded recovery runs on a timer and never checks the banlist | **Critical** | **Fixed** (security half; liveness half open) |
|
||||
| [A3](#a3) | The action layer has no permission checks; the UI's are ban-blind | High | **Fixed** |
|
||||
| [A4](#a4) | A banned member keeps broadcasting "typing", and we keep showing it | Low | **Fixed** |
|
||||
| [A5](#a5) | A banned member's own client keeps reading and rendering everything | Medium | Inherent — product decision |
|
||||
|
||||
### <a name="part-b"></a>Part B — requires a malicious client
|
||||
|
||||
| # | Finding | Severity | Needs a ban? | Status |
|
||||
|---|---------|----------|--------------|--------|
|
||||
| [B1](#b1) | One edition at `version = Long.MAX_VALUE` pins an entity forever | **Critical** | No — any bit-holder | **Fixed** |
|
||||
| [B2](#b2) | A banned staffer keeps Role/Grant/Banlist authority | **Critical** | Yes | **Fixed** (matches Armada) |
|
||||
| [B3](#b3) | A rogue rotator compacts the banlist away | High | Via B2 | **Mitigated** by B2 |
|
||||
| [B4](#b4) | The Refounding recipient set is attacker-inflatable | High | No | **Fixed** (bounded) |
|
||||
| [B5](#b5) | The ban is per-pubkey; the channel key is not revoked | High | Yes | Inherent — Refounding is the answer |
|
||||
| [B6](#b6) | Channel history is deletable on a naive third-party relay | High | Yes | Correct here; external relays at risk |
|
||||
| [B7](#b7) | The base-rekey plane is writable by every member | Low | Yes | Accepted |
|
||||
|
||||
### <a name="part-c"></a>Part C — interop and not-yet-shipped
|
||||
|
||||
| # | Finding | Severity | Status |
|
||||
|---|---------|----------|--------|
|
||||
| [C1](#c1) | Banlist rank rule diverges from Armada | Medium | Reported; B2 widens the divergence |
|
||||
| [C2](#c2) | CORD-07 voice rooms are key-gated, not roster-gated | Design | Note for whoever ships voice |
|
||||
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
**Status: fixed.** `mintConcordInvite` and its button now require `CREATE_INVITE` (or ownership).
|
||||
|
||||
|
||||
**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
|
||||
|
||||
**Status: security half fixed; liveness half open — and the fork is now resolved.** `isStranded` /
|
||||
`mergeForward` take `bannedAtCurrentEpoch` as a *required* argument, so a removed member is no longer
|
||||
walked back in. The open question was whether anything re-mints at a stable coordinate. **Armada
|
||||
does** — `useLinkRefreshWatch2` re-posts every bundle on each epoch change — so in any cross-client
|
||||
community this was a *live* removal bypass, not a hypothetical, and the fix was load-bearing. The
|
||||
liveness half stands: Amethyst re-mints nothing, so legitimate recovery never fires for an
|
||||
Amethyst-only community. See [the Armada comparison](#armada) for the two ways out.
|
||||
|
||||
|
||||
**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
|
||||
|
||||
**Status: fixed.** Authority now lives in `AccountConcordActions.isAuthorizedFor`, which every
|
||||
moderation verb funnels through, and every authorization test uses the ban-aware `hasPermission`.
|
||||
|
||||
|
||||
**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
|
||||
|
||||
**Status: fixed on both ends.**
|
||||
|
||||
|
||||
**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
|
||||
|
||||
**Status: inherent; no code change.** The cryptography cannot be fixed without a Refounding, so what
|
||||
is left is a product decision about how "Ban" and "Remove from community" are presented. Left for a
|
||||
design pass rather than guessed at here.
|
||||
|
||||
|
||||
**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
|
||||
|
||||
**Status: fixed.** The compaction arm tries the floor-anchored chain first and bounds the bootstrap
|
||||
jump at `EditionFold.MAX_COMPACTION_VERSION_JUMP`; `compactControlPlane` picks the chain head rather
|
||||
than raw max version. The three reproductions now assert the fixed behaviour, and
|
||||
`aGenuineCompactionJumpIsStillFollowed` pins the CORD-06 §3 tolerance the bound must not break.
|
||||
|
||||
|
||||
**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="b2"></a>B2 — A banned staffer keeps Role, Grant and Banlist authority
|
||||
|
||||
**Status: fixed. Not consensus-affecting after all** — see [Armada comparison](#armada). Armada
|
||||
already implements the same two-pass, so this brings us *into* line rather than out of it. One
|
||||
narrower divergence remains, described there. `AuthorityResolver.resolve` is now a bounded two-pass
|
||||
where authority only shrinks. Note the deliberate cascade it brings: every edition a banned member
|
||||
ever authored is dropped, so banning an admin also demotes everyone that admin promoted. That is the
|
||||
literal reading of CORD-04 §4 and it is what kills the sockpuppet, but a legitimate promotion by a
|
||||
later-banned admin vanishes with it and has to be re-issued.
|
||||
|
||||
**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="b3"></a>B3 — A rogue rotator compacts the banlist away
|
||||
|
||||
**Status: mitigated by B2.** The rotator this needed was the sockpuppet, which can no longer be
|
||||
minted. A *legitimately* privileged rotator can still omit the banlist, and `EntityFloor` remains the
|
||||
only defense for clients that already folded it — unchanged, and still worth a spec fix.
|
||||
|
||||
|
||||
**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 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 B2 removes the puppet and takes this with it; floors alone do not, since they only
|
||||
protect people who were already there.
|
||||
|
||||
## <a name="b4"></a>B4 — The Refounding recipient set is attacker-inflatable
|
||||
|
||||
**Status: fixed (bounded).** The recipient set is capped, the owner-rooted roster is kept first, and
|
||||
anything dropped is logged rather than silently truncated.
|
||||
|
||||
|
||||
**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="b5"></a>B5 — The ban is a per-pubkey display rule and the channel key is not revoked
|
||||
|
||||
**Status: inherent.** No client-side fix exists; a Refounding is the answer, which is why B4 mattered.
|
||||
|
||||
|
||||
**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 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 B4 matters so much.
|
||||
|
||||
## <a name="b6"></a>B6 — Channel history is deletable on a naive third-party relay
|
||||
|
||||
**Status: correct on our relay and pinned; external relays remain exposed.** Needs a CORD-01 spec note
|
||||
and relay-selection guidance, not code.
|
||||
|
||||
|
||||
**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="b7"></a>B7 — The base-rekey plane is writable by every member
|
||||
|
||||
**Status: accepted.** Bounded work per wrap, no correctness impact.
|
||||
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Part C — interop and not-yet-shipped
|
||||
|
||||
## <a name="c1"></a>C1 — 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 — B2 is the general form of it, and the fix proposed there
|
||||
resolves both.
|
||||
|
||||
## <a name="c2"></a>C2 — Voice rooms are key-gated, not roster-gated
|
||||
|
||||
**Design-level; not currently reachable.** *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.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## <a name="armada"></a>Armada comparison (checked 2026-08-09)
|
||||
|
||||
Read against `gitlab.com/soapbox-pub/armada` at `src/concord-v2/`. Worth doing before shipping any of
|
||||
this, and it changed two conclusions.
|
||||
|
||||
**B2 — they already do it, and we had it backwards.** `foldControlState` (`lib/control.ts`) runs the
|
||||
same bounded two-pass: fold once, take the banlist, and if any edition was authored by someone on it,
|
||||
re-fold with those editions excluded. Independently arrived at, same shape, same CORD-04 §4
|
||||
justification in the comment. So this change brings us *into* line with Armada rather than out of it,
|
||||
and the consensus warning in the earlier revision of this doc was wrong.
|
||||
|
||||
One real divergence remains, and it is ours to defend: Armada keeps **pass 1's** banlist as the final
|
||||
word ("the first pass's Banlist stays the final word"), while we recompute the banlist in pass 2. So
|
||||
a banned admin's mass-ban of everyone beneath them still stands in Armada and is dropped by us — the
|
||||
`aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll` case. Their stated reason is to stop
|
||||
the anti-roster erasing itself; ours is that an edition from a banned author should not survive its
|
||||
own author's removal. Both are defensible; ours closes an attack theirs leaves open, and the
|
||||
self-erasure they worry about is unreachable for us because the rank rule makes mutual bans
|
||||
impossible (only someone who strictly outranks you can ban you). Worth raising with them.
|
||||
|
||||
**B1 — the same bug, unfixed, in exactly the same place.** `bootstrapHead` (`lib/version.ts:155`)
|
||||
takes the highest version at or above the floor with no bound; `headCandidates` uses it for the
|
||||
compaction arm; `pickHead` then raises the stored floor to whatever won. That is the whole
|
||||
version-exhaustion chain. This is now the second bug both implementations share because both read
|
||||
the same section the same way, and it deserves the same treatment as the rank rule: a written report.
|
||||
|
||||
**A1 — ours alone.** Armada gates invite creation on `CREATE_INVITE` in both the hook
|
||||
(`useInvites2.ts`) and the page (`canCreateInvite`). We were the only client handing a banned member
|
||||
a working invite button.
|
||||
|
||||
**A2 — different architecture, and it is better.** Armada's catch-up is **push**, not pull: a
|
||||
privileged member sends a stranded member a direct invite carrying the fresher root
|
||||
(`useDirectInvites2`, `catchUp`), so a human authorizes each re-admission. `useRekeyWatch2` merely
|
||||
reports `{ stranded: boolean }` for the UI. They also ship `useBanSelfRemove2`: a banned member's own
|
||||
client silently drops the community from their private list — network-silent, deliberately narrower
|
||||
than rekey-exclusion, because "a rotation can be a mistake; a ban is a judgment". Our pull-from-my-own-
|
||||
old-link design is what made the bypass possible, and their per-epoch bundle refresh is what would
|
||||
have supplied the higher epoch to pull. Two ways forward: adopt a refresh of our own (restores
|
||||
liveness, keeps the pull design and its risk), or move to their push model (safer, and it is what the
|
||||
one existing implementation does).
|
||||
|
||||
**C1 — still open on their side.** `banlistGate` remains a bare `isAuthorized(roster, author, owner,
|
||||
BAN)`: no rank check, no delta rule. The divergence from
|
||||
`docs/concord-banlist-rank-conformance.md` is unchanged.
|
||||
|
||||
**A divergence in the other direction.** Armada's banlist takes only the gated head's content —
|
||||
there is no §4 re-heal union. Ours unions in every authorized non-ancestor edition, which is what
|
||||
defeats the genesis-fork laundering attempt in `BannedStaffEscalationTest`. So we honor concurrent
|
||||
bans they drop. Worth a spec question about which is normative.
|
||||
|
||||
**A4 — shared gap.** No ban filter on typing there either.
|
||||
|
||||
**B4 — not established.** I could not locate a recipient-set bound in their rekey path, but I also
|
||||
could not locate the recipient-set construction itself with confidence, so treat this as unchecked
|
||||
rather than as a finding either way.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Performance of the fixes
|
||||
|
||||
Measured on the JVM with a synthetic Control Plane (throwaway benchmark, not committed —
|
||||
`ConcordCommunityState.fold` over 226 and 2059 editions, 200 reps after warmup). Pass A of the
|
||||
two-pass resolver is byte-for-byte the old algorithm, so the single-pass rows below *are* the
|
||||
before-numbers.
|
||||
|
||||
| Case | µs / fold |
|
||||
|---|---|
|
||||
| 226 editions, no bans | 1457 |
|
||||
| 226 editions, 20 bans, none of them authors | 994 |
|
||||
| 226 editions, 20 bans, one an author → pass B runs | 1881 |
|
||||
| 2059 editions, no bans | 2194 |
|
||||
| 2059 editions, 50 bans, none of them authors | 2001 |
|
||||
| 2059 editions, 50 bans, one an author → pass B runs | 5697 |
|
||||
| 2059 editions, with floors (B1's compaction arm) | 2015 |
|
||||
|
||||
Two things to take from it.
|
||||
|
||||
**B1 costs nothing measurable.** Trying the floor-anchored chain before the raw-version bootstrap
|
||||
adds a per-entity version index on the compaction arm, but an entity carries a handful of editions,
|
||||
and the floored fold measures the same as the unfloored one.
|
||||
|
||||
**B2 costs a further fold, but only when it can change the answer.** `resolve` skips pass B when
|
||||
nobody is banned *or* when nobody banned ever authored a Control edition — the overwhelmingly common
|
||||
shape, since bans land on plain members who hold no role and write nothing. Those rows show no
|
||||
regression. When a banned member *did* author editions — a banned staffer, exactly the case B2 exists
|
||||
for — the fold costs ~2–3× more, and one more pass again in the rare case where a banned member had
|
||||
themselves authored a ban. That is the price of the fix and it is paid only by communities under the
|
||||
attack.
|
||||
|
||||
**Worth knowing, unrelated to this work:** Amethyst re-folds the whole buffer from scratch on every
|
||||
Control Plane change, and `resolve` runs once per held epoch inside `controlFloorsLocked` plus once
|
||||
in `fold`, so a refresh is already several folds. Armada memoizes the fold by
|
||||
`(community, owner, floors, snapshot, edition ids)`; we do not. That is the real optimization here,
|
||||
it predates these fixes, and it would also absorb the pass-B cost. Left alone deliberately — it is a
|
||||
change to make on its own merits, with its own measurements.
|
||||
|
||||
---
|
||||
|
||||
## What was NOT examined
|
||||
|
||||
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 (B2).
|
||||
|
||||
Not looked at at all:
|
||||
|
||||
- **Private channels** (CORD-03 derived keys) — key delivery on grant, and channel-scoped rekey.
|
||||
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 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.
|
||||
- Unread counts and notification triggers, media/upload references from messages, the NIP-53 nests
|
||||
overlap, and the desktop client's Concord paths.
|
||||
|
||||
## What is left
|
||||
|
||||
1. **A2's liveness half** — decide whether a community re-mints its invite bundle at a stable
|
||||
coordinate. Today nothing does, so stranded recovery never fires for anyone, and an owner evicted
|
||||
by a rogue admin has no route back. Needs a spec answer before code.
|
||||
2. **C1 / B2 interop** — tell Armada about the two-pass rule, as with the rank rule before it. The
|
||||
divergence is now wider: we drop editions they honor whenever a privileged member is banned.
|
||||
3. **B6** — a CORD-01 note that a plane's wraps must stay owned by a key nobody holds, plus guidance
|
||||
that a relay authorizing NIP-09/62 by `pubkey` hands every ex-member a wipe button.
|
||||
4. **B3's residue** — a legitimately privileged rotator can still omit an entity during compaction.
|
||||
`EntityFloor` catches it for clients that were present; fresh joiners have nothing.
|
||||
5. **A5** — a design pass on how "Ban" and "Remove from community" are presented, since they promise
|
||||
very different things.
|
||||
6. **The unexamined surfaces below**, particularly private channels — there appears to be no
|
||||
channel-scoped rekey receive path at all, which would mean the full-community Refounding is the
|
||||
only removal Amethyst can perform.
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* 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.geode
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Why a soft-banned member cannot delete a Concord community's history from the relay — and what
|
||||
* keeps it that way.
|
||||
*
|
||||
* The worry is real. CORD-01 inverts NIP-59: every wrap on a plane is signed by the *shared stream
|
||||
* key*, not by its author, and the true author only exists inside the encrypted seal. A member
|
||||
* banned yesterday still derives `group_key("concord/channel", community_root, channel_id, epoch)`
|
||||
* from the root they kept, so they can still sign events *as the channel itself*. If NIP-09 and
|
||||
* NIP-62 authorized on the outer `pubkey`, [Nip09DeletionTest]'s "a kind-5 from pubkey X cannot
|
||||
* delete pubkey Y's events" would be vacuous inside a plane: one event from any ex-member would
|
||||
* erase the whole community's history.
|
||||
*
|
||||
* What stops it is [com.vitorpamplona.quartz.nip01Core.store.owner]: a kind-1059 gift wrap is
|
||||
* controlled by its **p-tag recipient**, not its signer. Concord stamps a *freshly random* p-tag on
|
||||
* every wrap ([ConcordStreamEnvelope.wrapSeal]), so each wrap is owned by a one-time key that
|
||||
* nobody — attacker, author, or owner — ever holds. The channel is undeletable by construction.
|
||||
*
|
||||
* Both halves of that are load-bearing and neither was written for this reason, so both are pinned
|
||||
* here: [theEphemeralPTagIsWhatMakesTheChannelUndeletable] fails the moment the p-tag becomes a
|
||||
* real key, and the first two tests fail the moment ownership goes back to the signer.
|
||||
*
|
||||
* **Scope.** This is our relay's rule, not the protocol's. A community publishes wherever its
|
||||
* metadata points, and a third-party relay that reads NIP-09 the naive way — deletion authorized by
|
||||
* matching `pubkey` — hands every ex-member a wipe button for the whole channel. The protocol-level
|
||||
* fix is the same one that already exists for everything else: a CORD-06 Refounding rotates the
|
||||
* plane address, which protects the future but cannot restore what a relay already dropped.
|
||||
*/
|
||||
class ConcordPlaneKeyDeletionTest {
|
||||
private lateinit var hub: InProcessRelays
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var client: NostrClient
|
||||
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
hub = InProcessRelays()
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
client = NostrClient(hub, scope)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
hub.close()
|
||||
}
|
||||
|
||||
private suspend fun query(filter: Filter): List<Event> {
|
||||
val ch = Channel<Msg>(Channel.UNLIMITED)
|
||||
val subId = "sub-${System.nanoTime()}"
|
||||
client.subscribe(
|
||||
subId,
|
||||
mapOf(relayUrl to listOf(filter)),
|
||||
object : SubscriptionListener {
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(Msg.Ev(event))
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(Msg.Eose)
|
||||
}
|
||||
},
|
||||
)
|
||||
val events = mutableListOf<Event>()
|
||||
withTimeout(5000) {
|
||||
while (true) {
|
||||
when (val msg = ch.receive()) {
|
||||
is Msg.Ev -> events += msg.event
|
||||
Msg.Eose -> return@withTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
client.unsubscribe(subId)
|
||||
return events
|
||||
}
|
||||
|
||||
private sealed interface Msg {
|
||||
data class Ev(
|
||||
val event: Event,
|
||||
) : Msg
|
||||
|
||||
object Eose : Msg
|
||||
}
|
||||
|
||||
/** A public channel plane: derived from the community root, so every member holds its secret. */
|
||||
private val communityRoot = RandomInstance.bytes(32)
|
||||
private val channelId = RandomInstance.bytes(32)
|
||||
private val plane = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch = 0)
|
||||
|
||||
/** The plane's own signer — what the banned member reconstructs from the root they kept. */
|
||||
private fun planeSigner() = NostrSignerSync(KeyPair(privKey = plane.secretKey))
|
||||
|
||||
private suspend fun postAs(
|
||||
author: NostrSignerInternal,
|
||||
text: String,
|
||||
createdAt: Long,
|
||||
): Event {
|
||||
val rumor = ChannelChat.message(author.pubKey, channelId.toHexKey(), epoch = 0, text = text, createdAt = createdAt)
|
||||
return ConcordStreamEnvelope.wrap(rumor, plane, author, encrypted = true, createdAt = createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPlaneKeyHolderCannotDeleteTheChannelsHistory() =
|
||||
runBlocking {
|
||||
val now = TimeUtils.now()
|
||||
val bob = NostrSignerInternal(KeyPair())
|
||||
val carol = NostrSignerInternal(KeyPair())
|
||||
|
||||
val history =
|
||||
listOf(
|
||||
postAs(bob, "hello", now),
|
||||
postAs(carol, "hi bob", now + 1),
|
||||
postAs(bob, "how's the project going?", now + 2),
|
||||
)
|
||||
history.forEach { assertEquals(true, client.publishAndConfirm(it, setOf(relayUrl)), "seed the channel history") }
|
||||
assertEquals(3, query(Filter(authors = listOf(plane.publicKeyHex))).size, "three messages on the plane")
|
||||
|
||||
// The banned member still derives `plane`, and every wrap above IS authored by it — so
|
||||
// this kind-5 satisfies a same-author check. It must still be refused.
|
||||
val deletion = planeSigner().sign(DeletionEvent.build(history, createdAt = now + 10))
|
||||
assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl)), "the relay accepts the event itself")
|
||||
|
||||
assertEquals(
|
||||
3,
|
||||
query(Filter(authors = listOf(plane.publicKeyHex), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP))).size,
|
||||
"signing as the plane must NOT delete the community's messages",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPlaneKeyHolderCannotVanishTheChannelPlane() =
|
||||
runBlocking {
|
||||
val now = TimeUtils.now()
|
||||
val bob = NostrSignerInternal(KeyPair())
|
||||
|
||||
val history = listOf(postAs(bob, "one", now), postAs(bob, "two", now + 1))
|
||||
history.forEach { client.publishAndConfirm(it, setOf(relayUrl)) }
|
||||
assertEquals(2, query(Filter(authors = listOf(plane.publicKeyHex))).size)
|
||||
|
||||
// NIP-62 needs no per-event targeting: one event, and everything that pubkey published
|
||||
// is gone. The sharpest version of the attack, and the same rule has to stop it.
|
||||
val vanish = planeSigner().sign(RequestToVanishEvent.build(relayUrl, "", createdAt = now + 10))
|
||||
assertEquals(true, client.publishAndConfirm(vanish, setOf(relayUrl)))
|
||||
|
||||
assertEquals(
|
||||
2,
|
||||
query(Filter(authors = listOf(plane.publicKeyHex), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP))).size,
|
||||
"a kind-62 signed as the plane must not wipe the channel",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theEphemeralPTagIsWhatMakesTheChannelUndeletableSoDoNotMakeItMeaningful() =
|
||||
runBlocking {
|
||||
// The counterfactual, so the invariant is visible rather than incidental: ownership of a
|
||||
// 1059 follows the p-tag, so a wrap addressed to a REAL key is deletable by whoever holds
|
||||
// that key. Concord is safe only because `wrapSeal` stamps a fresh throwaway pubkey there.
|
||||
// If that p-tag ever becomes something a member holds — a recipient, a channel id, a
|
||||
// community id — every ex-holder of it can delete the plane's history.
|
||||
val now = TimeUtils.now()
|
||||
val mallory = NostrSignerInternal(KeyPair())
|
||||
|
||||
val addressedWrap =
|
||||
planeSigner().signNormal<Event>(
|
||||
now,
|
||||
ConcordStreamEnvelope.KIND_WRAP,
|
||||
arrayOf(arrayOf("p", mallory.pubKey)),
|
||||
"not-a-real-seal",
|
||||
)
|
||||
assertEquals(true, client.publishAndConfirm(addressedWrap, setOf(relayUrl)))
|
||||
assertEquals(1, query(Filter(ids = listOf(addressedWrap.id))).size)
|
||||
|
||||
val deletion = mallory.sign(DeletionEvent.build(listOf(addressedWrap), createdAt = now + 1))
|
||||
assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl)))
|
||||
|
||||
assertEquals(
|
||||
0,
|
||||
query(Filter(ids = listOf(addressedWrap.id))).size,
|
||||
"the p-tag recipient owns a 1059 — which is exactly why Concord's p-tag must stay random",
|
||||
)
|
||||
}
|
||||
}
|
||||
+97
-1
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Resolves the owner-rooted authority state of a Concord community from its
|
||||
@@ -132,12 +133,101 @@ data class AuthorityResolver private constructor(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConcordAuthorityResolver"
|
||||
|
||||
/** The owner's rank — supreme and unremovable. No Role may claim it. */
|
||||
const val OWNER_RANK = 0L
|
||||
|
||||
/**
|
||||
* How many times [resolve] will re-fold chasing a stable banlist. Real communities settle on
|
||||
* the first or second — the mask only moves when a banned member authored a *ban*, and it
|
||||
* stops moving as soon as those are gone. The cap is a termination backstop for an
|
||||
* adversarial edition set, not a tuning knob.
|
||||
*/
|
||||
private const val MAX_BAN_RESOLUTION_PASSES = 4
|
||||
|
||||
/**
|
||||
* The owner-rooted authority state of a community, with the banlist honored **against the
|
||||
* Control Plane itself** (CORD-04 §4: a reader "drops every event from a banned npub —
|
||||
* message, reaction, edit, or authority action").
|
||||
*
|
||||
* This is a bounded two-pass, because the rule is circular as stated: you cannot know who is
|
||||
* banned until you fold the Banlist, and you cannot decide who may write the Banlist without
|
||||
* knowing who is banned. `docs/concord-banlist-rank-conformance.md` §4 row 3 flagged that to
|
||||
* the spec authors and left it open. We resolve it by making authority only ever **shrink**:
|
||||
*
|
||||
* - **Pass A** resolves exactly as before, ban-blind, and yields a candidate banlist.
|
||||
* - **Pass B** re-resolves with every author in that banlist treated as unauthorized, for
|
||||
* roles, grants and the banlist alike.
|
||||
*
|
||||
* Two passes, always, so it terminates by construction — pass B never feeds back. It cannot
|
||||
* oscillate on mutual bans either, because the rank rule makes them unreachable: only a
|
||||
* member who strictly outranks you may ban you, and you cannot outrank them back.
|
||||
*
|
||||
* **This cascades, deliberately.** Every edition a banned member ever authored is dropped,
|
||||
* including grants they made while in good standing — so banning an admin also demotes
|
||||
* everyone that admin promoted. That is the literal reading of §4, and it is the point: the
|
||||
* escalation in `docs/concord-soft-ban-audit.md` B2 was a banned staffer minting a fresh,
|
||||
* un-banned npub and acting through it, and dropping the grant is what kills the puppet. The
|
||||
* cost is that a legitimate promotion by a later-banned admin vanishes too, and the owner has
|
||||
* to re-issue it.
|
||||
*
|
||||
* **Consensus-affecting.** Armada gates the Control Plane on role-derived permissions alone,
|
||||
* so until it ships the same rule the two clients can disagree about any community where a
|
||||
* privileged member was banned.
|
||||
*/
|
||||
fun resolve(
|
||||
editions: Collection<ControlEdition>,
|
||||
ownerPubKey: String,
|
||||
): AuthorityResolver {
|
||||
val passA = resolveOnce(editions, ownerPubKey, bannedAuthors = emptySet())
|
||||
// A further pass costs a whole fold, so skip it unless it could change something. Nobody
|
||||
// banned, or nobody banned who ever wrote to the Control Plane — the overwhelmingly common
|
||||
// shape, since most bans land on plain members who hold no role and author no editions —
|
||||
// and the next pass is provably identical to this one. Armada's fold checks the same.
|
||||
if (passA.banned.isEmpty()) return passA
|
||||
if (editions.none { it.author.lowercase() in passA.banned }) return passA
|
||||
|
||||
// Iterate to a fixpoint where the mask a pass was resolved UNDER equals the banlist that
|
||||
// pass produced. Stopping at two passes leaves those two disagreeing, and the disagreement
|
||||
// is not cosmetic: a moderator whose only ban came from an admin the owner banned
|
||||
// concurrently is released by pass 2 — correctly — but pass 2 dropped her editions too,
|
||||
// because she was on pass 1's list. The fold then reports her as a moderator in good
|
||||
// standing whose promotions have silently vanished, and it does so deterministically, so
|
||||
// she never gets them back.
|
||||
//
|
||||
// The mask cannot simply be assumed to shrink: masking an author can strip a THIRD
|
||||
// member's role, dropping their rank to "roleless", which lets a junior BAN holder who
|
||||
// could not previously reach them ban them after all. So this is bounded rather than
|
||||
// proven monotone, and it keeps the last pass it computed if it somehow does not settle —
|
||||
// still strictly better than the two-pass answer, and it always terminates.
|
||||
var mask = passA.banned
|
||||
var result = passA
|
||||
repeat(MAX_BAN_RESOLUTION_PASSES) {
|
||||
result = resolveOnce(editions, ownerPubKey, bannedAuthors = mask)
|
||||
if (result.banned == mask) return result
|
||||
mask = result.banned
|
||||
}
|
||||
// Exhausted the cap without settling. The returned roster was folded under a mask that is
|
||||
// no longer the banlist beside it, so this is reported rather than swallowed — the same
|
||||
// reasoning as EditionFold.LOG_GAP: an unsettled fold is either an adversarial edition set
|
||||
// or a rule of ours that does not converge, and both are things a reader wants to know.
|
||||
Log.w(TAG) {
|
||||
"Banlist resolution did not settle in $MAX_BAN_RESOLUTION_PASSES passes for owner $ownerPubKey " +
|
||||
"(${editions.size} editions, ${result.banned.size} banned): keeping the last pass"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolution pass. [bannedAuthors] are treated as holding no authority at all — their
|
||||
* role, grant and banlist editions are dropped rather than merely being unable to act on
|
||||
* others. Empty on pass A; pass A's banlist on pass B. See [resolve].
|
||||
*/
|
||||
private fun resolveOnce(
|
||||
editions: Collection<ControlEdition>,
|
||||
ownerPubKey: String,
|
||||
bannedAuthors: Set<String>,
|
||||
): AuthorityResolver {
|
||||
val ownerLower = ownerPubKey.lowercase()
|
||||
|
||||
@@ -191,6 +281,7 @@ data class AuthorityResolver private constructor(
|
||||
): Boolean {
|
||||
val author = e.author.lowercase()
|
||||
if (author == ownerLower) return true
|
||||
if (author in bannedAuthors) return false
|
||||
if (!holdsManageRoles(author)) return false
|
||||
val authorRank = rankOf(author) ?: return false
|
||||
val r = ConcordJson.decodeOrNull<RoleEntity>(e.content) ?: return false
|
||||
@@ -223,6 +314,7 @@ data class AuthorityResolver private constructor(
|
||||
fun grantGate(e: ControlEdition): Boolean {
|
||||
val granter = e.author.lowercase()
|
||||
if (granter == ownerLower) return true
|
||||
if (granter in bannedAuthors) return false
|
||||
if (!holdsManageRoles(granter)) return false
|
||||
val granterRank = rankOf(granter) ?: return false
|
||||
val g = ConcordJson.decodeOrNull<GrantEntity>(e.content) ?: return false
|
||||
@@ -269,7 +361,10 @@ data class AuthorityResolver private constructor(
|
||||
// a concurrent ban is never lost, while an on-chain unban still takes effect.
|
||||
val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST }
|
||||
|
||||
fun banGate(e: ControlEdition): Boolean = e.author.lowercase() == ownerLower || effectivePermissionsOf(e.author.lowercase()).has(ConcordPermissions.BAN)
|
||||
fun banGate(e: ControlEdition): Boolean {
|
||||
val author = e.author.lowercase()
|
||||
return author == ownerLower || (author !in bannedAuthors && effectivePermissionsOf(author).has(ConcordPermissions.BAN))
|
||||
}
|
||||
val authorizedBanlist = allBanlist.filter(::banGate)
|
||||
|
||||
// CORD-04 §3's rank rule binds "every action", and it names banning as its example ("an
|
||||
@@ -292,6 +387,7 @@ data class AuthorityResolver private constructor(
|
||||
// owner is never a valid target — not even for themselves.
|
||||
if (target == ownerLower) return false
|
||||
if (author == ownerLower) return true
|
||||
if (author in bannedAuthors) return false
|
||||
if (!effectivePermissionsOf(author).has(ConcordPermissions.BAN)) return false
|
||||
val authorRank = rankOf(author) ?: return false
|
||||
val targetRank = rankOf(target) ?: Long.MAX_VALUE // no roles ⇒ lowest authority
|
||||
|
||||
+63
-3
@@ -150,9 +150,63 @@ object EditionFold {
|
||||
floorVersion: Long,
|
||||
): ControlEdition? =
|
||||
editions
|
||||
.filter { it.version >= floorVersion }
|
||||
.filter { it.version >= floorVersion && it.version - floorVersion <= MAX_COMPACTION_VERSION_JUMP }
|
||||
.minWithOrNull(compareByDescending<ControlEdition> { it.version }.thenBy { it.rumorId })
|
||||
|
||||
/**
|
||||
* How far above the floor the compaction arm will follow an edition in one step.
|
||||
*
|
||||
* The arm trades contiguity for cross-epoch tolerance, which made VERSION the only contest an
|
||||
* edition had to win — so a single authorized edition at `version = Long.MAX_VALUE` used to
|
||||
* become an entity's permanent head: it won the arm, `authorizedHeads` raised the floor to
|
||||
* `Long.MAX_VALUE`, and from there no honest edition could ever exceed the floor again. Even a
|
||||
* Refounding that dropped the poison did not help, because nothing was then offered at or above
|
||||
* the floor and the fold fell back to [EntityFloor.known] — the poison itself. See B1 in
|
||||
* `docs/concord-soft-ban-audit.md`.
|
||||
*
|
||||
* A compacted head is legitimately ahead of the floor by however many editions the entity gained
|
||||
* while we were away — a chain's worth, not 2^63. This bound is deliberately far above any real
|
||||
* community (a channel renamed a thousand times a day for three years stays under it) and far
|
||||
* below the point where the version space can be exhausted. Anything beyond it is not a
|
||||
* compaction we missed; it is someone reaching for the ceiling, and it is treated as a gap.
|
||||
*/
|
||||
const val MAX_COMPACTION_VERSION_JUMP = 1_000_000L
|
||||
|
||||
/**
|
||||
* The floor-anchored chain head among [editions], or null when nothing connects to [floor].
|
||||
*
|
||||
* The same anchor-then-walk the main path uses, factored out so the compaction arm can try it
|
||||
* first: the anchor is the floor's own edition (same version AND hash) or its immediate
|
||||
* successor citing that hash, then the walk climbs while each `version + 1` cites the current
|
||||
* head. Reports no gap — a null here means "fall back", not "refuse".
|
||||
*/
|
||||
private fun chainHead(
|
||||
editions: List<ControlEdition>,
|
||||
floor: EntityFloor,
|
||||
): ControlEdition? {
|
||||
val byVersion = HashMap<Long, MutableList<ControlEdition>>()
|
||||
for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e)
|
||||
|
||||
val lowest = byVersion.keys.filter { it >= floor.version }.minOrNull() ?: return null
|
||||
val winner = byVersion[lowest]?.minByOrNull { it.rumorId } ?: return null
|
||||
var head =
|
||||
when (lowest) {
|
||||
floor.version -> winner.takeIf { it.hashHex == floor.hashHex }
|
||||
floor.version + 1 -> winner.takeIf { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex }
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
while (true) {
|
||||
val next =
|
||||
byVersion[head.version + 1]
|
||||
?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex }
|
||||
?.minByOrNull { it.rumorId }
|
||||
?: break
|
||||
head = next
|
||||
}
|
||||
return head
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups mixed [editions] by entity id and folds each to its head, honoring the
|
||||
* per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]).
|
||||
@@ -210,10 +264,16 @@ object EditionFold {
|
||||
// to the compacted head. Presence of the entity in the snapshot selects the ARM;
|
||||
// version selects the HEAD, over every edition we hold and not just the subset.
|
||||
if (floor != null && snapshot != null && editions.any { it.rumorId in snapshot }) {
|
||||
// Chain first, bootstrap only as the fallback. The arm exists for the case where the
|
||||
// offered head genuinely cannot be connected — but when it CAN be, the connected head is
|
||||
// strictly better evidence than "highest number wins", and preferring it denies a stray
|
||||
// high-version edition its free win in every ordinary fold. The bootstrap keeps the
|
||||
// cross-epoch case working, now bounded by MAX_COMPACTION_VERSION_JUMP.
|
||||
chainHead(editions, floor)?.let { return it }
|
||||
return bootstrapHead(editions, floor.version)
|
||||
?: run {
|
||||
// Nothing at or above the floor was served: the head we already accepted
|
||||
// vanished from the offered set — withheld, so fail closed.
|
||||
// Nothing admissible at or above the floor was served: the head we already
|
||||
// accepted vanished from the offered set — withheld, so fail closed.
|
||||
onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version })
|
||||
floor.known
|
||||
}
|
||||
|
||||
+15
-2
@@ -46,12 +46,24 @@ object ConcordStrandedRecovery {
|
||||
* True when [bundle], resolved at [entry]'s stored invite link, proves we were
|
||||
* left behind: it must describe the same community and sit at a strictly higher
|
||||
* epoch. Same or lower is a no-op (we are current, or the bundle is stale).
|
||||
*
|
||||
* [bannedAtCurrentEpoch] is the caller's answer to "does the community, as I fold
|
||||
* it right now, have me on its banlist?" — and a `true` refuses the recovery
|
||||
* outright. It is a required argument rather than a caller-side `if` because
|
||||
* getting it wrong turns this mechanism inside out: recovery exists so a member
|
||||
* *wrongly* omitted from a rotation can catch up, but the test it performs (a
|
||||
* higher epoch at a link whose unlock token an ex-member keeps forever) cannot
|
||||
* tell that member apart from one the community deliberately removed. Without
|
||||
* this, a Refounding — the only hard removal Concord has — is undone by our own
|
||||
* background sweep a few minutes later.
|
||||
*/
|
||||
fun isStranded(
|
||||
entry: ConcordCommunityListEntry,
|
||||
bundle: CommunityInvite,
|
||||
bannedAtCurrentEpoch: Boolean,
|
||||
): Boolean =
|
||||
entry.inviteRef != null &&
|
||||
!bannedAtCurrentEpoch &&
|
||||
entry.inviteRef != null &&
|
||||
bundle.communityId.equals(entry.id, ignoreCase = true) &&
|
||||
bundle.rootEpoch > entry.rootEpoch
|
||||
|
||||
@@ -73,8 +85,9 @@ object ConcordStrandedRecovery {
|
||||
fun mergeForward(
|
||||
entry: ConcordCommunityListEntry,
|
||||
bundle: CommunityInvite,
|
||||
bannedAtCurrentEpoch: Boolean,
|
||||
): ConcordCommunityListEntry? {
|
||||
if (!isStranded(entry, bundle)) return null
|
||||
if (!isStranded(entry, bundle, bannedAtCurrentEpoch)) return null
|
||||
|
||||
// Bank the epoch we are leaving with its control_pk, so its Control Plane
|
||||
// stays re-subscribable for the anti-rollback floor (a split epoch's address
|
||||
|
||||
+32
-8
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord06Rekey
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
@@ -123,11 +124,12 @@ object ConcordRefounding {
|
||||
recipientsXOnly: List<HexKey>,
|
||||
staffXOnly: Set<HexKey>,
|
||||
createdAt: Long,
|
||||
ownerPubKey: HexKey,
|
||||
): RefoundingBuild {
|
||||
val newEpoch = rootEpoch + 1
|
||||
val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot)
|
||||
|
||||
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys)
|
||||
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, ownerPubKey)
|
||||
|
||||
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch)
|
||||
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
|
||||
@@ -166,19 +168,41 @@ object ConcordRefounding {
|
||||
priorWraps: List<Event>,
|
||||
priorControlKeys: ControlPlaneKeys,
|
||||
newControlKeys: ControlPlaneKeys,
|
||||
ownerPubKey: HexKey,
|
||||
): List<Event> {
|
||||
// entity coordinate -> (head edition, its verified seal)
|
||||
val heads = HashMap<String, Pair<ControlEdition, Event>>()
|
||||
// entity coordinate -> every edition we can open, paired with its verified seal.
|
||||
val byCoordinate = HashMap<String, MutableList<Pair<ControlEdition, Event>>>()
|
||||
for (wrap in priorWraps) {
|
||||
val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKeys) ?: continue
|
||||
val edition = ControlEdition.fromRumor(opened.rumor) ?: continue
|
||||
val coord = edition.entityKind.wire + ":" + edition.entityIdHex
|
||||
val current = heads[coord]
|
||||
if (current == null || edition.version > current.first.version) {
|
||||
heads[coord] = edition to opened.seal
|
||||
}
|
||||
byCoordinate.getOrPut(coord) { ArrayList() }.add(edition to opened.seal)
|
||||
}
|
||||
return heads.values.map { (_, seal) -> ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt) }
|
||||
|
||||
// The head to carry forward is the one every READER honors — the authority-gated head — not
|
||||
// the highest version and not the bare structural chain head.
|
||||
//
|
||||
// Raw highest version made an honest rotator the delivery mechanism for a disconnected stray:
|
||||
// an edition minted at an arbitrary version never joins the chain, but it won that comparison
|
||||
// and was re-wrapped into the new epoch as the entity's whole history (B1 in
|
||||
// `docs/concord-soft-ban-audit.md`). The bare chain walk is *worse*, and this is the trap:
|
||||
// with no floor it anchors at the lowest-version edition carrying no `prev`, and after a prior
|
||||
// compaction the real head's `prev` dangles by design — so a forged `version = 1, prev = null`
|
||||
// decoy outranks a genuine v50→v52 chain and, because nothing here checks signatures, becomes
|
||||
// the entity's entire carried-forward state. A forged empty banlist would erase every ban.
|
||||
//
|
||||
// Gating on the owner-rooted roster is the only selection that cannot be gamed by an
|
||||
// unprivileged author, and it is exactly what ConcordCommunityState.fold would seat, so the
|
||||
// compacted epoch starts where the previous one left off.
|
||||
val editions = byCoordinate.values.flatten()
|
||||
val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, ownerPubKey)
|
||||
val out = ArrayList<Event>(honored.size)
|
||||
for ((_, floor) in honored) {
|
||||
val head = floor.known ?: continue
|
||||
val seal = editions.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue
|
||||
out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
* 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.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* **B2 in `docs/concord-soft-ban-audit.md` — regression guard.** What a soft-banned staffer used to
|
||||
* be able to do to a community, and can no longer. Every test here failed before the two-pass rule
|
||||
* in [AuthorityResolver.resolve] and passes after it.
|
||||
*
|
||||
* The asymmetry they were written to pin: [ConcordCommunityState.fold] gates METADATA / CHANNEL /
|
||||
* INVITE through `authority.hasPermission`, which is `!isBanned && …`, but ROLE, GRANT and BANLIST
|
||||
* were gated *inside* [AuthorityResolver.resolve] by `holdsManageRoles` / `bitsOf` /
|
||||
* `effectivePermissionsOf` — none of which consulted the banlist, nor could they as written, since
|
||||
* the roles/grants fixpoint ran before `banned` was computed at all. Half the Control Plane honored
|
||||
* a ban and half was structurally blind to it, so a banned member still holding `control_root` kept
|
||||
* full authority over the roster: they banned everyone beneath them, revoked the surviving
|
||||
* moderators, retired the roles under them, and minted a fresh un-banned npub that passed every
|
||||
* ban-aware gate and finished the job.
|
||||
*
|
||||
* The fix resolves the ordering by making authority only ever shrink across two passes — see
|
||||
* [AuthorityResolver.resolve]. Note that a chain-local rule ("the author must not be banned by the
|
||||
* state their edition chains from") would NOT have been enough:
|
||||
* [aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan] forks at genesis so no
|
||||
* parent ever mentions the ban, and CORD-04 §4's re-heal union would carry it in anyway. The rule
|
||||
* had to bind the union too, which is why it is expressed as a whole-pass mask.
|
||||
*
|
||||
* Three tests pin behaviour the fix had to *preserve* rather than change:
|
||||
* [selfUnbanIsStillRefused], [aJuniorPuppetCannotLiftASeniorsBan], and
|
||||
* [aBanByOneAdminDoesNotDropTheGrantsOfAnother]. One pins the cost it deliberately accepts:
|
||||
* [banningAnAdminAlsoDemotesEveryoneThatAdminPromoted].
|
||||
*/
|
||||
class BannedStaffEscalationTest {
|
||||
private val owner = "0f".repeat(32)
|
||||
private val alice = "a1".repeat(32) // Admin, position 1 — the member who gets banned
|
||||
private val bob = "b2".repeat(32) // Mod, position 5
|
||||
private val carol = "c3".repeat(32) // plain member, no role
|
||||
private val puppet = "e5".repeat(32) // a fresh npub alice controls
|
||||
|
||||
private val adminRole = "11".repeat(32)
|
||||
private val modRole = "22".repeat(32)
|
||||
private val puppetRole = "33".repeat(32)
|
||||
|
||||
private val banlistEntity = "44".repeat(32)
|
||||
private val channelEntity = "55".repeat(32)
|
||||
private val metadataEntity = "66".repeat(32)
|
||||
private val bobGrantEntity = "32".repeat(32)
|
||||
private val puppetGrantEntity = "35".repeat(32)
|
||||
|
||||
// MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|KICK|BAN|CREATE_INVITE = 1+2+4+8+16+64
|
||||
private val adminJson = """{"name":"Admin","position":1,"permissions":"95"}"""
|
||||
private val modJson = """{"name":"Mod","position":5,"permissions":"24"}""" // KICK|BAN
|
||||
|
||||
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)
|
||||
|
||||
private fun role(
|
||||
id: String,
|
||||
json: String,
|
||||
author: String = owner,
|
||||
version: Long = 0,
|
||||
prev: ByteArray? = null,
|
||||
) = edition(ControlEntityKind.ROLE, id, version, prev, json, author, "role-$id-$version-$author")
|
||||
|
||||
private fun grant(
|
||||
coordinate: String,
|
||||
member: String,
|
||||
roleIds: List<String>,
|
||||
author: String,
|
||||
version: Long = 0,
|
||||
prev: ByteArray? = null,
|
||||
) = edition(
|
||||
ControlEntityKind.GRANT,
|
||||
coordinate,
|
||||
version,
|
||||
prev,
|
||||
"""{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""",
|
||||
author,
|
||||
"grant-$coordinate-$version-$author",
|
||||
)
|
||||
|
||||
private fun banlist(
|
||||
author: String,
|
||||
version: Long,
|
||||
prev: ByteArray?,
|
||||
vararg banned: String,
|
||||
) = edition(
|
||||
ControlEntityKind.BANLIST,
|
||||
banlistEntity,
|
||||
version,
|
||||
prev,
|
||||
"[${banned.joinToString(",") { "\"$it\"" }}]",
|
||||
author,
|
||||
"ban-$version-$author",
|
||||
)
|
||||
|
||||
private fun channel(
|
||||
json: String,
|
||||
author: String,
|
||||
version: Long,
|
||||
prev: ByteArray?,
|
||||
) = edition(ControlEntityKind.CHANNEL, channelEntity, version, prev, json, author, "chan-$version-$author")
|
||||
|
||||
private fun metadata(
|
||||
json: String,
|
||||
author: String,
|
||||
version: Long,
|
||||
prev: ByteArray?,
|
||||
) = edition(ControlEntityKind.METADATA, metadataEntity, version, prev, json, author, "meta-$version-$author")
|
||||
|
||||
private val channelV0 = channel("""{"name":"general"}""", owner, 0, null)
|
||||
private val metadataV0 = metadata("""{"name":"My Community"}""", owner, 0, null)
|
||||
private val bobGrantV0 = grant(bobGrantEntity, bob, listOf(modRole), owner)
|
||||
private val modRoleV0 = role(modRole, modJson)
|
||||
|
||||
/** The owner-authored community every test starts from: two roles, two grants, a channel, metadata. */
|
||||
private fun community() =
|
||||
mutableListOf(
|
||||
role(adminRole, adminJson),
|
||||
modRoleV0,
|
||||
grant("31".repeat(32), alice, listOf(adminRole), owner),
|
||||
bobGrantV0,
|
||||
channelV0,
|
||||
metadataV0,
|
||||
)
|
||||
|
||||
/** The owner bans alice. Genesis of the banlist, so every test can fork or chain off it. */
|
||||
private val ownerBansAlice = banlist(owner, 0, null, alice)
|
||||
|
||||
/** Alice, already banned, mints a role just below herself and hands it to a fresh npub. */
|
||||
private fun aliceMintsAPuppet() =
|
||||
listOf(
|
||||
role(puppetRole, """{"name":"Puppet","position":2,"permissions":"95"}""", author = alice),
|
||||
grant(puppetGrantEntity, puppet, listOf(puppetRole), author = alice),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun aBanStripsTheAuthorityCheckedByFoldButNotTheOneCheckedByTheResolver() {
|
||||
val r = AuthorityResolver.resolve(community() + ownerBansAlice, owner)
|
||||
|
||||
assertTrue(r.isBanned(alice), "the owner's ban lands")
|
||||
assertFalse(r.hasPermission(alice, ConcordPermissions.MANAGE_ROLES), "the ban-aware check refuses her")
|
||||
// effectivePermissions still reports what her ROLES say — that is its job, and the members
|
||||
// screen reads it to label her. What changed is that the resolver's own ROLE/GRANT/BANLIST
|
||||
// gates no longer consult it for a banned author; they drop the edition outright.
|
||||
assertTrue(
|
||||
r.effectivePermissions(alice).has(ConcordPermissions.MANAGE_ROLES),
|
||||
"the role-derived view is unchanged — only what it authorizes is",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBannedAdminPromotesAFreshSockpuppetToAdmin() {
|
||||
val r = AuthorityResolver.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner)
|
||||
|
||||
assertEquals(null, r.rank(puppet), "the banned admin's role and grant editions are both dropped")
|
||||
assertFalse(r.isBanned(puppet), "the puppet itself is a clean npub — it is never banned, just powerless")
|
||||
assertFalse(
|
||||
r.hasPermission(puppet, ConcordPermissions.MANAGE_CHANNELS),
|
||||
"a banned member cannot mint authority it no longer has to give",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSockpuppetDeletesEveryChannelAndRewritesTheMetadata() {
|
||||
val editions =
|
||||
community() + ownerBansAlice + aliceMintsAPuppet() +
|
||||
// A channel tombstone is terminal — CORD-03: the id is never reused.
|
||||
channel("""{"name":"general","deleted":true}""", puppet, 1, channelV0.hash) +
|
||||
metadata("""{"name":"Owned by the guy you banned"}""", puppet, 1, metadataV0.hash)
|
||||
|
||||
val state = ConcordCommunityState.fold(editions, owner)
|
||||
|
||||
assertEquals(1, state.channels.size, "the puppet holds nothing, so its tombstone is inert")
|
||||
assertEquals("My Community", state.metadata?.name, "and the community keeps its identity")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSockpuppetBansEveryMemberBeneathIt() {
|
||||
val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash, alice, bob, carol)
|
||||
|
||||
val r = AuthorityResolver.resolve(editions, owner)
|
||||
|
||||
assertFalse(r.isBanned(bob), "the puppet's banlist edition is unauthorized, so the moderator stands")
|
||||
assertFalse(r.isBanned(carol), "and so do the plain members")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll() {
|
||||
val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash, alice, bob, carol)
|
||||
|
||||
val r = AuthorityResolver.resolve(editions, owner)
|
||||
|
||||
assertTrue(r.isBanned(alice), "her own ban stands — it was the owner's")
|
||||
assertFalse(r.isBanned(bob), "banGate now drops a banned author's edition outright")
|
||||
assertFalse(r.isBanned(carol), "same")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan() {
|
||||
// The same attack as above, except her edition does NOT chain onto the edition that banned
|
||||
// her — it forks at genesis. So a rule that only asks "was the author banned by this
|
||||
// edition's parent?" never sees her ban, and CORD-04 §4's re-heal union carries her bans in
|
||||
// regardless. Any fix has to bind the union, not just the chain.
|
||||
val editions = community() + ownerBansAlice + banlist(alice, 0, null, bob, carol)
|
||||
|
||||
val r = AuthorityResolver.resolve(editions, owner)
|
||||
|
||||
assertTrue(r.isBanned(alice), "the owner's ban survives the fork — the union is down-only")
|
||||
assertFalse(r.isBanned(bob), "the fix binds the UNION too: her fork is dropped before it can be healed in")
|
||||
assertFalse(r.isBanned(carol), "same")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBannedAdminRevokesTheSurvivingModerators() {
|
||||
val editions = community() + ownerBansAlice + grant(bobGrantEntity, bob, emptyList(), author = alice, version = 1, prev = bobGrantV0.hash)
|
||||
|
||||
val r = AuthorityResolver.resolve(editions, owner)
|
||||
|
||||
assertEquals(5, r.rank(bob), "a banned admin's revoke is dropped, so the moderator keeps their role")
|
||||
assertTrue(r.hasPermission(bob, ConcordPermissions.BAN), "and keeps the authority that comes with it")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBannedAdminDeletesEveryRoleBeneathThem() {
|
||||
val tombstone = role(modRole, """{"name":"Mod","position":5,"permissions":"24","deleted":true}""", author = alice, version = 1, prev = modRoleV0.hash)
|
||||
|
||||
val r = AuthorityResolver.resolve(community() + ownerBansAlice + tombstone, owner)
|
||||
|
||||
assertEquals(5, r.roles()[modRole]?.position, "a banned admin's tombstone is dropped, so the role survives")
|
||||
assertEquals(5, r.rank(bob), "and its holders keep their standing")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selfUnbanIsStillRefused() {
|
||||
// docs/concord-banlist-rank-conformance.md §4 row 3, the half that IS closed: the delta rule
|
||||
// gates removals too, and strict outranking means nobody outranks themselves.
|
||||
val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash)
|
||||
|
||||
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a banned member may not lift their own ban")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aJuniorPuppetCannotLiftASeniorsBan() {
|
||||
// The puppet sits at position 2 and alice at 1, and no edition may claim a position at or
|
||||
// above its own signer — so her delegation chain can only ever descend. Nothing she mints
|
||||
// can outrank her, and so nothing she mints can unban her.
|
||||
val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash)
|
||||
|
||||
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aForkedBanlistThatOmitsHimCannotLaunderTheBanAway() {
|
||||
// A malicious client is not limited to what the ban/unban verb will author. The sharpest
|
||||
// hand-crafted route does not try to REMOVE his ban — removal is what the strict-outrank-self
|
||||
// rule guards — it forks at genesis and simply never mentions him, at a version high enough
|
||||
// to win the head fold. The head's own effective list then never carried his ban, so there is
|
||||
// nothing to remove and the rank rule never fires.
|
||||
//
|
||||
// §4's re-heal is what closes it: the owner's edition is authorized and is NOT on the forked
|
||||
// head's back-chain, so it is unioned back in as a concurrent ban.
|
||||
val editions = community() + ownerBansAlice + banlist(alice, 99, null, carol)
|
||||
|
||||
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the re-heal union must put the owner's ban back")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPrivateBanlistChainOfHisOwnCannotLaunderTheBanAway() {
|
||||
// The same idea two editions deep, so the winning head has a clean ancestry entirely of his
|
||||
// own making. Ancestry is walked over the full pool, so the owner's ban is still recognised
|
||||
// as a concurrent fork rather than a superseded ancestor.
|
||||
val mine = banlist(alice, 50, null)
|
||||
val editions = community() + ownerBansAlice + mine + banlist(alice, 51, mine.hash)
|
||||
|
||||
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a self-authored chain must not launder the ban away")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRogueRotatorCompactsTheBanAwayForEveryClientWithoutAFloor() {
|
||||
// The route that does work, and the one no signature check can catch. A CORD-06 §3 compaction
|
||||
// re-wraps ONE edition per entity and the ROTATOR picks it, so a rotator can simply not carry
|
||||
// the banlist forward. Every edition it serves is genuine; the ban is erased by omission.
|
||||
//
|
||||
// A banned member cannot rotate (drainConcordRekeys gates the rotator on hasPermission, which
|
||||
// is ban-aware) — but the puppet minted above is not banned, and it can. EntityFloor is the
|
||||
// whole defense, so this splits the community in two: clients that already folded the ban
|
||||
// refuse the rollback, while fresh joiners have no floor to refuse with and see no ban at all.
|
||||
val editions = community() + ownerBansAlice
|
||||
val floors = ConcordCommunityState.authorizedHeads(editions, owner)
|
||||
val compacted = editions.filter { it.entityKind != ControlEntityKind.BANLIST }
|
||||
|
||||
assertFalse(
|
||||
ConcordCommunityState.fold(compacted, owner).authority.isBanned(alice),
|
||||
"ESCALATION: a fresh joiner holds no floor, so the omitted ban simply never existed",
|
||||
)
|
||||
assertTrue(
|
||||
ConcordCommunityState.fold(compacted, owner, floors).authority.isBanned(alice),
|
||||
"a client that already folded the ban must refuse the rollback",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun banningAnAdminAlsoDemotesEveryoneThatAdminPromoted() {
|
||||
// The deliberate cascade, pinned because it is surprising and because it is the whole point.
|
||||
// CORD-04 §4 drops every event from a banned npub, authority actions included, so a grant
|
||||
// they made while in good standing goes too. That is what kills a sockpuppet minted moments
|
||||
// before the ban — and the same rule costs the owner a legitimate promotion, which they have
|
||||
// to re-issue. See B2 in docs/concord-soft-ban-audit.md.
|
||||
val promoted = grant("36".repeat(32), carol, listOf(modRole), author = alice)
|
||||
|
||||
val before = AuthorityResolver.resolve(community() + promoted, owner)
|
||||
assertEquals(5, before.rank(carol), "while alice is in good standing, her grant stands")
|
||||
|
||||
val after = AuthorityResolver.resolve(community() + promoted + ownerBansAlice, owner)
|
||||
assertEquals(null, after.rank(carol), "banning alice retroactively drops the grant she authored")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBanByOneAdminDoesNotDropTheGrantsOfAnother() {
|
||||
// The cascade must follow the banned author, not spread. Bob is untouched by alice's ban, so
|
||||
// everything he authored keeps standing.
|
||||
val carolByBob = grant("37".repeat(32), carol, listOf(modRole), author = bob)
|
||||
// bob is a Mod at position 5 and the role he hands out is that same position, so the grant is
|
||||
// only honored when authored by someone who outranks it — the owner does, bob does not.
|
||||
val carolByOwner = grant("38".repeat(32), carol, listOf(modRole), author = owner)
|
||||
|
||||
val r = AuthorityResolver.resolve(community() + ownerBansAlice + carolByBob + carolByOwner, owner)
|
||||
|
||||
assertTrue(r.isBanned(alice), "alice is the only one banned")
|
||||
assertEquals(5, r.rank(bob), "bob is untouched")
|
||||
assertEquals(5, r.rank(carol), "and the owner's grant of carol stands")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMemberReleasedByTheSecondPassKeepsTheEditionsTheyAuthored() {
|
||||
// The mask a pass resolves UNDER has to equal the banlist that pass produces, or the fold
|
||||
// reports a state that contradicts itself. Concretely: the rogue admin bans a moderator while
|
||||
// the owner concurrently bans the rogue. The moderator is correctly released — the only ban on
|
||||
// her came from someone who turned out to be banned — but a fold that stops after two passes
|
||||
// has already dropped her editions, because she was on the FIRST pass's list. She then reads
|
||||
// as a moderator in good standing whose promotions silently vanished, deterministically and
|
||||
// forever. resolve() iterates until the two agree.
|
||||
val juniorRole = "23".repeat(32)
|
||||
val seniorRole = "24".repeat(32)
|
||||
val editions =
|
||||
community() +
|
||||
// the baseline Mod role carries no MANAGE_ROLES, so give bob one that can grant
|
||||
role(seniorRole, """{"name":"Senior","position":5,"permissions":"95"}""") +
|
||||
grant(bobGrantEntity, bob, listOf(seniorRole), author = owner, version = 1, prev = bobGrantV0.hash) +
|
||||
role(juniorRole, """{"name":"Junior","position":9,"permissions":"8"}""") +
|
||||
// bob promotes carol himself, while in good standing
|
||||
grant("39".repeat(32), carol, listOf(juniorRole), author = bob) +
|
||||
// the rogue admin bans bob...
|
||||
banlist(alice, 0, null, bob) +
|
||||
// ...while the owner concurrently bans the rogue, never naming bob
|
||||
ownerBansAlice
|
||||
|
||||
val r = AuthorityResolver.resolve(editions, owner)
|
||||
|
||||
assertTrue(r.isBanned(alice), "the owner's ban of the rogue stands")
|
||||
assertFalse(r.isBanned(bob), "and the rogue's ban of the moderator falls with them")
|
||||
assertEquals(5, r.rank(bob), "the released moderator keeps their own role")
|
||||
assertEquals(9, r.rank(carol), "and the promotion they authored survives with them")
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* **B1 in `docs/concord-soft-ban-audit.md` — regression guard.** A single Control Plane edition at
|
||||
* `version = Long.MAX_VALUE` used to pin its entity to the author's content permanently, for every
|
||||
* client that held a floor for it. These tests failed before the fix and pass after it.
|
||||
*
|
||||
* The chain walk was never the weakness — it advances only to `head.version + 1` citing the head's
|
||||
* hash, so an inflated version is unreachable and a fresh joiner was unaffected. The weakness was the
|
||||
* **compaction arm** of `EditionFold.foldEntity`: once a client held a floor for an entity and that
|
||||
* entity appeared in the epoch snapshot (which `ConcordCommunityState.fold` always builds from the
|
||||
* editions handed to it), the head came from the raw-version bootstrap — *highest version at or above
|
||||
* the floor*, with no `prev`, no hash, and no contiguity. Version was then the whole contest, and
|
||||
* `Long.MAX_VALUE` won it forever:
|
||||
*
|
||||
* 1. the poison became the head, so the entity showed the attacker's content;
|
||||
* 2. `authorizedHeads` raised the entity's floor to `Long.MAX_VALUE`;
|
||||
* 3. no honest edition could ever exceed that floor, so the entity could never be repaired;
|
||||
* 4. a Refounding that dropped the poison did not help either — nothing was then offered at or above
|
||||
* the floor, so the fold reported a gap and fell back to `EntityFloor.known`, which *was* the poison.
|
||||
*
|
||||
* Two changes close it, and both are pinned below. The arm now tries the floor-anchored **chain**
|
||||
* first and only falls back to the raw-version bootstrap when nothing connects, so a stray never wins
|
||||
* a fold where the honest chain is present; and the bootstrap will not follow a jump larger than
|
||||
* [EditionFold.MAX_COMPACTION_VERSION_JUMP], so the version space cannot be exhausted in one step.
|
||||
* [aGenuineCompactionJumpIsStillFollowed] pins the tolerance the arm exists for, so the bound cannot
|
||||
* be tightened into breaking CORD-06 §3.
|
||||
*
|
||||
* 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
|
||||
* could do this at any time, and demoting or banning them afterwards changed nothing, because the
|
||||
* damage was already in every client's floor. `ConcordRefounding.compactControlPlane` also selected
|
||||
* the head per entity by raw highest version, which made an honest rotator the delivery mechanism —
|
||||
* it now picks the chain head instead.
|
||||
*
|
||||
* The banlist was the one entity that survived, 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 landed even when the head was poisoned. [aPoisonedBanlistStillAcceptsTheOwnersBan]
|
||||
* keeps pinning that, because it was 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 oneEditionAtMaxVersionNoLongerPinsTheMetadata() {
|
||||
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(0, floorsAfter[metadataEntity]?.version, "the floor must not follow a stray to 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(
|
||||
"My Community",
|
||||
ConcordCommunityState.fold(pool, owner, floorsAfter).metadata?.name,
|
||||
"a client holding a floor follows the honest chain, not the stray",
|
||||
)
|
||||
assertEquals(
|
||||
"My Community",
|
||||
ConcordCommunityState.fold(community + repair, owner, floorsAfter).metadata?.name,
|
||||
"and a Refounding that drops the poison stays repaired",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneEditionAtMaxVersionNoLongerDeletesAChannel() {
|
||||
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(1, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "and so does a client holding a floor")
|
||||
assertEquals(
|
||||
1,
|
||||
ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size,
|
||||
"the channel survives a Refounding too",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPoisonedBanlistStillAcceptsTheOwnersBan() {
|
||||
// This was the saving grace before the fix — the reason the bug was "community with a broken
|
||||
// name" rather than "community nobody can moderate". 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 landed even while the banlist's floor sat at Long.MAX_VALUE. The floor can
|
||||
// no longer be poisoned, but keep this: do not "unify" the banlist onto the floored fold
|
||||
// without replacing the 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(0, floorsAfter[banlistEntity]?.version, "the banlist floor is no longer poisonable either")
|
||||
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aGenuineCompactionJumpIsStillFollowed() {
|
||||
// The tolerance the compaction arm exists for, pinned so the bound above cannot be tightened
|
||||
// into breaking CORD-06 §3. After a Refounding the compacted head carries the `prev` it had
|
||||
// before compaction, citing an edition in the PRIOR epoch that this client no longer holds —
|
||||
// so it connects to nothing, and its version is legitimately several ahead of our floor.
|
||||
val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0")
|
||||
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0)
|
||||
val floors = ConcordCommunityState.authorizedHeads(community, owner)
|
||||
|
||||
val danglingPrev = ByteArray(32) { 0x7f }
|
||||
val compacted = edition(ControlEntityKind.METADATA, metadataEntity, 4, danglingPrev, """{"name":"Renamed While We Were Away"}""", owner, "meta-compacted")
|
||||
|
||||
assertEquals(
|
||||
"Renamed While We Were Away",
|
||||
ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + compacted, owner, floors).metadata?.name,
|
||||
"a compacted head whose prev dangles by design must still be adopted",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aJumpBeyondTheCapIsRefusedAsAGap() {
|
||||
// Same shape as the genuine compaction above, one version past the bound: not a compaction we
|
||||
// missed, so the fold reports a gap and keeps what it already had rather than following it.
|
||||
val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0")
|
||||
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0)
|
||||
val floors = ConcordCommunityState.authorizedHeads(community, owner)
|
||||
|
||||
val danglingPrev = ByteArray(32) { 0x7f }
|
||||
val tooFar =
|
||||
edition(
|
||||
ControlEntityKind.METADATA,
|
||||
metadataEntity,
|
||||
EditionFold.MAX_COMPACTION_VERSION_JUMP + 1,
|
||||
danglingPrev,
|
||||
"""{"name":"PWNED"}""",
|
||||
bob,
|
||||
"meta-far",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"My Community",
|
||||
ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + tooFar, owner, floors).metadata?.name,
|
||||
"a jump past the bound is a gap, not a head",
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -82,7 +82,7 @@ class ConcordStrandedRecoveryTest {
|
||||
val prior = HeldRoot(0L, "aa".repeat(32))
|
||||
val stranded = entry(epoch = 1, heldRoots = listOf(prior))
|
||||
|
||||
val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5))
|
||||
val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false)
|
||||
assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind")
|
||||
|
||||
// adopted the new epoch's access root
|
||||
@@ -106,27 +106,27 @@ class ConcordStrandedRecoveryTest {
|
||||
|
||||
@Test
|
||||
fun sameEpochBundleIsANoOp() {
|
||||
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5)))
|
||||
assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5)))
|
||||
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false))
|
||||
assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lowerEpochBundleIsANoOp() {
|
||||
// Epoch-monotonic: a stale bundle must never walk the membership backwards.
|
||||
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3)))
|
||||
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3), bannedAtCurrentEpoch = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun entryWithoutInviteRefIsInert() {
|
||||
// Direct invites and legacy entries have no anchor — expected, not an error.
|
||||
val noAnchor = entry(epoch = 1, ref = null)
|
||||
assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9)))
|
||||
assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9)))
|
||||
assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false))
|
||||
assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bundleForAnotherCommunityIsIgnored() {
|
||||
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32))))
|
||||
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)), bannedAtCurrentEpoch = false))
|
||||
}
|
||||
|
||||
// ---- the bare `<naddr>#<fragment>` anchor form ----------------------------
|
||||
@@ -252,4 +252,16 @@ class ConcordStrandedRecoveryTest {
|
||||
assertEquals(4L, other.rootEpoch)
|
||||
assertEquals(inviteRef, other.inviteRef)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBannedMemberDoesNotRecoverIntoTheEpochTheyWereRemovedFrom() {
|
||||
// The removal case the higher-epoch test cannot tell apart on its own: an ex-member keeps the
|
||||
// link's unlock token forever, so without the ban gate the recovery sweep merges them into the
|
||||
// very epoch a Refounding rotated them out of. See A2 in docs/concord-soft-ban-audit.md.
|
||||
val stranded = entry(epoch = 1)
|
||||
assertFalse(ConcordStrandedRecovery.isStranded(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true))
|
||||
assertNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true))
|
||||
// ...and the legitimate case still works, so the gate is not just "recovery off".
|
||||
assertNotNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false))
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -74,6 +74,7 @@ class ConcordRefoundingTest {
|
||||
recipientsXOnly = listOf(alice.pubKey, bob.pubKey),
|
||||
staffXOnly = setOf(owner.pubKey),
|
||||
createdAt = now,
|
||||
ownerPubKey = owner.pubKey,
|
||||
)
|
||||
|
||||
assertEquals(community.rootEpoch + 1, build.newEpoch)
|
||||
@@ -113,6 +114,7 @@ class ConcordRefoundingTest {
|
||||
recipientsXOnly = listOf(alice.pubKey),
|
||||
staffXOnly = setOf(owner.pubKey),
|
||||
createdAt = now,
|
||||
ownerPubKey = owner.pubKey,
|
||||
)
|
||||
|
||||
val newControl = build.newControlKeys
|
||||
@@ -184,6 +186,7 @@ class ConcordRefoundingTest {
|
||||
recipientsXOnly = listOf(alice.pubKey),
|
||||
staffXOnly = setOf(owner.pubKey),
|
||||
createdAt = now,
|
||||
ownerPubKey = owner.pubKey,
|
||||
)
|
||||
|
||||
val newControl = build.newControlKeys
|
||||
@@ -223,6 +226,7 @@ class ConcordRefoundingTest {
|
||||
recipientsXOnly = listOf(alice.pubKey),
|
||||
staffXOnly = setOf(owner.pubKey),
|
||||
createdAt = now,
|
||||
ownerPubKey = owner.pubKey,
|
||||
)
|
||||
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch)
|
||||
|
||||
@@ -230,4 +234,76 @@ class ConcordRefoundingTest {
|
||||
val wrongRoot = ByteArray(32) { 0x11 }
|
||||
assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, community.communityId, wrongRoot, community.rootEpoch))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compactionRefusesAForgedGenesisAndCarriesTheAuthorizedHead() =
|
||||
runTest {
|
||||
// A compaction re-wraps ONE edition per entity and nothing downstream re-checks the
|
||||
// choice, so how that edition is picked is a security decision, not a detail.
|
||||
//
|
||||
// Raw highest-version lets a stray at an arbitrary version through. But the bare
|
||||
// structural chain walk is worse: with no floor it anchors at the lowest-version edition
|
||||
// carrying no `prev`, and after a PRIOR compaction the real head's `prev` dangles into a
|
||||
// trimmed epoch by design — so a forged `version = 1, prev = null` decoy outranks a
|
||||
// genuine v50→v52 chain, and becomes the entity's entire carried-forward state. A forged
|
||||
// empty banlist would erase every ban that way. Only the owner-rooted gate is safe.
|
||||
val community = ConcordCommunityFactory.create(owner, "Test", now)
|
||||
val communityId = community.communityId
|
||||
val control = community.controlPlane
|
||||
|
||||
// The metadata entity, already compacted once: its head chains from an epoch we no longer hold.
|
||||
val danglingPrev = ByteArray(32) { 0x7F }
|
||||
val realHead =
|
||||
ConcordStreamEnvelope.wrap(
|
||||
ControlEditionBuilder.rumor(
|
||||
owner.pubKey,
|
||||
ControlEntityKind.METADATA,
|
||||
communityId,
|
||||
50,
|
||||
danglingPrev,
|
||||
ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "Real")),
|
||||
now,
|
||||
null,
|
||||
),
|
||||
control,
|
||||
owner,
|
||||
encrypted = false,
|
||||
createdAt = now,
|
||||
)
|
||||
|
||||
// carol holds nothing at all and mints a genesis-shaped decoy at version 1.
|
||||
val forged =
|
||||
ConcordStreamEnvelope.wrap(
|
||||
ControlEditionBuilder.rumor(
|
||||
carol.pubKey,
|
||||
ControlEntityKind.METADATA,
|
||||
communityId,
|
||||
1,
|
||||
null,
|
||||
ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "PWNED")),
|
||||
now,
|
||||
null,
|
||||
),
|
||||
control,
|
||||
carol,
|
||||
encrypted = false,
|
||||
createdAt = now,
|
||||
)
|
||||
|
||||
val newEpoch = community.rootEpoch + 1
|
||||
val newControl =
|
||||
com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
.forStaff(newRoot, communityId, newEpoch, newControlRoot)
|
||||
val compacted = ConcordRefounding.compactControlPlane(listOf(realHead, forged), control, newControl, owner.pubKey)
|
||||
|
||||
val carried =
|
||||
compacted
|
||||
.mapNotNull { ConcordStreamEnvelope.openOrNull(it, newControl) }
|
||||
.mapNotNull { ControlEdition.fromRumor(it.rumor) }
|
||||
.filter { it.entityKind == ControlEntityKind.METADATA }
|
||||
|
||||
assertEquals(1, carried.size, "one metadata edition carried forward")
|
||||
assertEquals(50, carried.single().version, "the owner's real head, not the forged genesis")
|
||||
assertEquals("Real", ConcordJson.decodeOrNull<MetadataEntity>(carried.single().content)?.name)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -109,6 +109,7 @@ class ControlRootRotationTest {
|
||||
recipientsXOnly = listOf(owner.pubKey, moderator.pubKey, member.pubKey),
|
||||
staffXOnly = setOf(owner.pubKey, moderator.pubKey),
|
||||
createdAt = now,
|
||||
ownerPubKey = owner.pubKey,
|
||||
)
|
||||
|
||||
val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch)
|
||||
@@ -149,6 +150,7 @@ class ControlRootRotationTest {
|
||||
recipientsXOnly = listOf(owner.pubKey, member.pubKey),
|
||||
staffXOnly = setOf(owner.pubKey),
|
||||
createdAt = now,
|
||||
ownerPubKey = owner.pubKey,
|
||||
)
|
||||
|
||||
// The rotator's own view writes; a member's view of the same epoch only reads.
|
||||
|
||||
Reference in New Issue
Block a user