mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +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:
+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