mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
fix(quartz): stop a stray edition from pinning a Control entity forever
B1 in docs/concord-soft-ban-audit.md, the worst item on the list: one edition at version = Long.MAX_VALUE permanently pinned its entity to the author's content, for every client holding a floor for it, with no way back. The floor rose to MAX_VALUE, no honest edition could exceed it, and a Refounding that dropped the poison fell back to EntityFloor.known — the poison. Authored in the tests by a current, legitimately granted moderator: no ban, no sockpuppet, one ordinary permission bit. The chain walk was never the weakness; it advances only to head.version + 1 citing the head's hash, so a fresh joiner was untouched. The compaction arm was: it trades contiguity for cross-epoch tolerance, which left VERSION as the only contest an edition had to win. Two changes. The arm now tries the floor-anchored chain first and falls back to the raw-version bootstrap only when nothing connects, so a stray never wins a fold where the honest chain is present. And the bootstrap will not follow a jump of more than MAX_COMPACTION_VERSION_JUMP above the floor — a compacted head is legitimately ahead by a chain's worth, not by 2^63 — so the version space cannot be exhausted in a step. A new test pins the tolerance the arm exists for, so the bound cannot later be tightened into breaking CORD-06 §3. compactControlPlane picked its per-entity head by raw highest version too, which made an honest rotator the delivery mechanism: a disconnected stray never joins the chain but won that comparison, and was re-wrapped into the new epoch as the entity's whole history, where fresh joiners anchor on it. It now picks the chain head, keeping foldEntity's fresh-joiner fallback for the dangling `prev` a prior compaction leaves behind. The three reproductions now assert the fixed behaviour. The banlist's escape hatch (a floor-less chain walk plus the re-heal union) is kept and still pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DrJhpFhhLjuDJQNkGvYMGj
This commit is contained in:
+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
|
||||
}
|
||||
|
||||
+19
-7
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.quartz.concord.cord06Rekey
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
@@ -167,18 +168,29 @@ object ConcordRefounding {
|
||||
priorControlKeys: ControlPlaneKeys,
|
||||
newControlKeys: ControlPlaneKeys,
|
||||
): 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 is the CHAIN head, not the highest version. Picking by raw 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 this comparison and was then re-wrapped into the
|
||||
// new epoch as that entity's whole history — where a fresh joiner, holding no floor, anchors
|
||||
// on it as their baseline. See B1 in `docs/concord-soft-ban-audit.md`. foldEntity walks from
|
||||
// genesis and keeps the fresh-joiner fallback for a head whose own `prev` dangles into an
|
||||
// epoch this rotator no longer holds, which is the ordinary shape after a prior compaction.
|
||||
val out = ArrayList<Event>(byCoordinate.size)
|
||||
for ((_, entries) in byCoordinate) {
|
||||
val head = EditionFold.foldEntity(entries.map { it.first }) ?: continue
|
||||
val seal = entries.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue
|
||||
out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+94
-38
@@ -27,35 +27,43 @@ import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* **V1 in `docs/concord-soft-ban-audit.md` — reproduction.** A single Control Plane edition at
|
||||
* `version = Long.MAX_VALUE` pins its entity to the author's content permanently, for every client
|
||||
* that holds a floor for it.
|
||||
* **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 is not the weakness — it advances only to `head.version + 1` citing the head's
|
||||
* hash, so an inflated version is unreachable and a fresh joiner is unaffected. The weakness is the
|
||||
* **compaction arm** of [EditionFold.foldEntity]: once a client holds a floor for an entity and that
|
||||
* entity appears in the epoch snapshot (which `ConcordCommunityState.fold` always builds from the
|
||||
* editions handed to it), the head is chosen by [EditionFold.bootstrapHead] — *highest version at or
|
||||
* above the floor*, with no `prev`, no hash, and no contiguity. Version is then the whole contest,
|
||||
* and `Long.MAX_VALUE` wins it forever:
|
||||
* 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 becomes the head, 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 either — nothing is offered at or above the
|
||||
* floor, so the fold reports a gap and falls back to [EntityFloor.known], which *is* the poison.
|
||||
* 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
|
||||
* do this at any time, and demoting or banning them afterwards changes nothing, because the damage
|
||||
* is already in every client's floor. It is also carried into every future epoch by
|
||||
* `ConcordRefounding.compactControlPlane`, which selects the head per entity by raw highest version.
|
||||
* 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 is the one entity that survives, and by accident: `AuthorityResolver` folds it with
|
||||
* 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 lands even when the head is poisoned. [aPoisonedBanlistStillAcceptsTheOwnersBan] pins
|
||||
* that, because it is the only thing standing between this bug and a permanently unmoderatable
|
||||
* community.
|
||||
* 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)
|
||||
@@ -86,7 +94,7 @@ class ControlPlaneVersionExhaustionTest {
|
||||
) + rest
|
||||
|
||||
@Test
|
||||
fun oneEditionAtMaxVersionPinsTheMetadataForever() {
|
||||
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)
|
||||
|
||||
@@ -96,7 +104,7 @@ class ControlPlaneVersionExhaustionTest {
|
||||
|
||||
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(Long.MAX_VALUE, floorsAfter[metadataEntity]?.version, "VULNERABLE: the floor is now at the top of the version space")
|
||||
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")
|
||||
@@ -108,19 +116,19 @@ class ControlPlaneVersionExhaustionTest {
|
||||
"a fresh joiner walks the chain and is unaffected",
|
||||
)
|
||||
assertEquals(
|
||||
"PWNED",
|
||||
"My Community",
|
||||
ConcordCommunityState.fold(pool, owner, floorsAfter).metadata?.name,
|
||||
"VULNERABLE: every client holding a floor is pinned to the attacker's content",
|
||||
"a client holding a floor follows the honest chain, not the stray",
|
||||
)
|
||||
assertEquals(
|
||||
"PWNED",
|
||||
"My Community",
|
||||
ConcordCommunityState.fold(community + repair, owner, floorsAfter).metadata?.name,
|
||||
"VULNERABLE: even a Refounding that drops the poison falls back to it as EntityFloor.known",
|
||||
"and a Refounding that drops the poison stays repaired",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneEditionAtMaxVersionDeletesAChannelForever() {
|
||||
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)
|
||||
|
||||
@@ -132,28 +140,29 @@ class ControlPlaneVersionExhaustionTest {
|
||||
val pool = community + poison + repair
|
||||
|
||||
assertEquals(1, ConcordCommunityState.fold(pool, owner).channels.size, "a fresh joiner still sees the channel")
|
||||
assertEquals(0, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "VULNERABLE: the channel is gone and cannot be restored")
|
||||
assertEquals(1, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "and so does a client holding a floor")
|
||||
assertEquals(
|
||||
0,
|
||||
1,
|
||||
ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size,
|
||||
"VULNERABLE: dropping the poison does not bring the channel back",
|
||||
"the channel survives a Refounding too",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPoisonedBanlistStillAcceptsTheOwnersBan() {
|
||||
// The saving grace, and the reason this bug is "unmoderatable community" rather than
|
||||
// "community with a broken name". 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 lands
|
||||
// even while the banlist's own floor sits at Long.MAX_VALUE. Do not "unify" the banlist onto
|
||||
// the floored fold without replacing this protection.
|
||||
// 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(Long.MAX_VALUE, floorsAfter[banlistEntity]?.version, "the banlist floor is poisoned like any other")
|
||||
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
|
||||
@@ -164,4 +173,51 @@ class ControlPlaneVersionExhaustionTest {
|
||||
"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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user