From fac1bf5b5d317fd5dfb8105dde7209f2e6f47971 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 20:36:25 -0400 Subject: [PATCH] feat(concord): refuse Control-Plane rollbacks with a version floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConcordRefounding.compactControlPlane` re-wraps one edition per entity when a community rotates epoch, and the ROTATOR chooses which one survives. The receiving side had no memory: `refold()` folds only the wraps at the current epoch's Control-Plane address and discards the prior epoch's buffer, and `EditionFold` accepts whatever it is handed (its no-genesis fallback anchors at the lowest version present). So a rotator could publish only version 1 of a chain and omit version 2 — restoring a revoked role, clearing a banlist, reverting metadata. Every signature is genuine; this is rollback by omission, not forgery. Adds a per-entity floor: the version AND hash last successfully folded. - **No floor (fresh joiner)** — unchanged: genesis anchor, else the lowest-version edition as the legitimate compaction bootstrap. - **With a floor** — the walk is anchored AT the floor: the offered set must contain the exact edition already folded (version and hash; a same-version sibling is a fork, not our chain), then walks up. A head below the floor is structurally unreachable. - **Gap** (the floor edition is absent) — refuse, and keep the known head. Refusing by *retaining* matters here: this fold is recomputed from scratch each time, so letting an entity vanish would itself be a rollback — a dropped banlist is an unban. The floor needs no new persistence. It is derived from `heldRoots`, the rotated-out access roots already persisted in the NIP-44 self-encrypted kind-13302 list: the session derives each prior epoch's Control-Plane address from them, folds oldest-first, and takes the resulting heads as the floor. That survives both a process restart and the session rebuild `ConcordSessionRegistry.sync` performs at exactly the moment of a Refounding — which would have destroyed any in-session floor. If the old planes are not served, there is no floor and behaviour is as before. Floors are built from AUTHORITY-GATED heads, not raw ones. Without that, any ex-member still holding a rotated-out root could mint a high-version edition on the old plane and freeze the entity for every honest client — a denial of service this change would otherwise have introduced. Covered by a test. Verified by disabling both enforcement points: 7 of 12 quartz tests and the end-to-end commons test fail, and the ones that still pass are exactly the non-regression cases (fresh joiner, honest compaction, pass-through without floors). Known limit: `AuthorityResolver.resolve` folds authorized SUBSETS of the edition pool and does not carry floors itself; gating happens at the pool level before the resolver sees anything. Sound, but connectivity checked on the full set is a weaker precondition than on each subset — passing floors into the resolver's three folds is worth a follow-up. Co-Authored-By: Claude Opus 4.8 --- .../actions/ConcordSubscriptionPlanner.kt | 28 ++- .../model/concord/ConcordCommunitySession.kt | 77 +++++- .../model/concord/ConcordSessionRegistry.kt | 3 + .../model/concord/ConcordRollbackFloorTest.kt | 233 ++++++++++++++++++ .../cord02Community/ConcordCommunityState.kt | 66 ++++- .../quartz/concord/cord04Roles/EditionFold.kt | 173 +++++++++++-- .../cord04Roles/EditionFoldFloorTest.kt | 174 +++++++++++++ 7 files changed, 729 insertions(+), 25 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldFloorTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 22024d73f3..98096f4ce8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -55,11 +55,31 @@ data class ConcordPlaneSub( * ([channelPlaneSubs]). */ object ConcordSubscriptionPlanner { - /** Control-plane subscriptions for every joined community (known from the entry alone). */ + /** + * Control-plane subscriptions for every joined community (known from the entry alone) — at + * the current epoch **plus** every prior epoch the account still holds a root for. + * + * The prior-epoch Control Planes are what a CORD-06 Refounding's compacted head is checked + * against: the rotator picks which edition per entity survives the rotation, so without the + * old planes a client has no memory of the versions it already folded and a rotator can roll + * an entity backwards (restore a revoked role, clear a banlist) with genuine signatures. See + * `ConcordCommunitySession.historicalControlPlaneAddresses`. + */ fun controlPlaneSubs(entries: List): List = - entries.map { e -> - val cp = ConcordActions.controlPlane(e.root.hexToByteArray(), e.id.hexToByteArray(), e.rootEpoch) - ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = normalize(e.relays)) + entries.flatMap { e -> + val communityId = e.id.hexToByteArray() + val relays = normalize(e.relays) + val cp = ConcordActions.controlPlane(e.root.hexToByteArray(), communityId, e.rootEpoch) + val historical = + e.heldRoots + .filter { it.epoch < e.rootEpoch } + .sortedByDescending { it.epoch } + .take(ConcordActions.MAX_BACKFILL_EPOCHS) + .mapNotNull { held -> + val key = runCatching { ConcordActions.controlPlane(held.key.hexToByteArray(), communityId, held.epoch) }.getOrNull() ?: return@mapNotNull null + ConcordPlaneSub(channelId = null, pubKeyHex = key.publicKeyHex, relays = relays) + } + listOf(ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = relays)) + historical } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 46f8aa8573..9bb65aef67 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold +import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.Event @@ -113,10 +115,40 @@ class ConcordCommunitySession( /** The next-epoch base-rekey stream address to watch for an inbound Refounding. */ val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex + /** + * The Control Plane of every **prior** epoch we still hold a root for (address -> + * key + epoch), newest-held first and bounded like the channel backfill. + * + * This is the anti-rollback memory. A CORD-06 Refounding re-wraps one edition per + * entity at the new epoch and the *rotator* chooses which one, so it can serve v1 + * of a chain that had already reached v2 — restoring a revoked role, clearing a + * banlist — with every signature genuine. Folding the epochs we still hold roots + * for gives us the [EntityFloor] each entity must connect to, and because + * `heldRoots` is already persisted in the kind-13302 community list, that memory + * survives a process restart without any new storage. + */ + private val historicalControlKeys: Map> = + entry.heldRoots + .filter { it.epoch < entry.rootEpoch } + .sortedByDescending { it.epoch } + .take(ConcordActions.MAX_BACKFILL_EPOCHS) + .mapNotNull { held -> + val key = runCatching { ConcordActions.controlPlane(held.key.hexToByteArray(), communityIdBytes, held.epoch) }.getOrNull() ?: return@mapNotNull null + key.publicKeyHex to (key to held.epoch) + }.toMap() + + /** The prior-epoch Control Plane addresses to subscribe to, so the rollback floor can be rebuilt. */ + fun historicalControlPlaneAddresses(): Set = historicalControlKeys.keys + private val lock = KmpLock() // Deduped inbound wraps. private val controlWraps = LinkedHashMap() + + // Prior-epoch Control Plane address -> (wrapId -> wrap). Kept apart from [controlWraps]: these + // never join the live fold, they only produce the anti-rollback floor. + private val historicalControlWraps = HashMap>() + private val channelWrapsById = HashMap>() // channelIdHex -> (wrapId -> wrap) private val guestbookWraps = LinkedHashMap() private val baseRekeyWraps = LinkedHashMap() @@ -236,6 +268,9 @@ class ConcordCommunitySession( fun streamKeys(): List = lock.withLock { listOf(controlPlaneKey) + + // Prior-epoch Control Planes: the anti-rollback floor is folded from them, so the + // gated relays must serve their wraps too. + historicalControlKeys.values.map { it.first } + channelKeysByAddress.values.map { it.second } + // Prior-epoch channel stream keys so the gated relays serve their older wraps too. historicalChannelKeysByAddress.values.map { it.second } @@ -297,6 +332,17 @@ class ConcordCommunitySession( return ConcordIngestOutcome.STRUCTURAL } else -> { + // A prior-epoch Control Plane wrap: buffer it and re-fold, so the anti-rollback + // floor rises as the old epochs drain in. Structural — the floor can change the + // folded state (and therefore the plane set) exactly like a live control wrap. + if (wrap.pubKey in historicalControlKeys) { + lock.withLock { + val buffer = historicalControlWraps.getOrPut(wrap.pubKey) { LinkedHashMap() } + if (buffer.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup + } + refold() + return ConcordIngestOutcome.STRUCTURAL + } val current = lock.withLock { channelKeysByAddress[wrap.pubKey] } if (current != null) { val (channelIdHex, key) = current @@ -374,7 +420,12 @@ class ConcordCommunitySession( val newChannels = lock.withLock { val wraps = controlWraps.values.toList() - val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner) + val folded = + ConcordCommunityState.fold( + ConcordActions.controlEditions(wraps, controlPlaneKey), + entry.owner, + controlFloorsLocked(), + ) val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first } val next = HashMap>() @@ -404,6 +455,30 @@ class ConcordCommunitySession( for (channelIdHex in newChannels) reprojectChannel(channelIdHex) } + /** + * The per-entity anti-rollback floor: the authority-gated heads of every prior epoch's + * Control Plane we still hold a root for, folded **oldest epoch first** so each epoch is + * itself anchored at the one before it and the floor only ever rises. + * + * The current epoch must then connect to these heads; an entity whose offered chain cannot + * reach its floor keeps the state we last folded (see [EditionFold.admissible]) and the + * refusal is warned. Empty for a fresh joiner (no held roots), which is exactly right — it + * legitimately has no history and must still accept the compacted head as its baseline. + * + * Caller must hold [lock]; a fold reads the wrap buffers. + */ + private fun controlFloorsLocked(): Map { + if (historicalControlKeys.isEmpty()) return emptyMap() + var floors = emptyMap() + for ((address, keyAtEpoch) in historicalControlKeys.entries.sortedBy { it.value.second }) { + val wraps = historicalControlWraps[address]?.values?.toList() ?: continue + val editions = ConcordActions.controlEditions(wraps, keyAtEpoch.first) + if (editions.isEmpty()) continue + floors = ConcordCommunityState.authorizedHeads(editions, entry.owner, floors) + } + return floors + } + private fun refoldGuestbook() { lock.withLock { val wraps = guestbookWraps.values.toList() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index 02adde62fe..cb366ba6fa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -93,6 +93,9 @@ class ConcordSessionRegistry( val out = HashSet() for (session in sessions.values) { out += session.controlPlaneAddress + // Prior-epoch Control Planes too: folding them is what gives each entity its + // anti-rollback floor across a CORD-06 Refounding. + out += session.historicalControlPlaneAddresses() out += session.channelAddresses() } out diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt new file mode 100644 index 0000000000..f571432ccc --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt @@ -0,0 +1,233 @@ +/* + * 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.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Session-level anti-rollback (CORD-06 §3). + * + * `ConcordRefounding.compactControlPlane` re-wraps ONE edition per entity when a community + * rotates its root, and the ROTATOR picks which one. A rotator that simply omits the newest + * edition of a chain walks the entity backwards — a revoked role restored, a banlist cleared, + * metadata reverted — with every signature genuine. The defense is memory: the account already + * persists the rotated-out roots (`heldRoots`, in the NIP-44 self-encrypted kind-13302 list), so + * the session re-derives each prior epoch's Control Plane, folds it, and requires the new epoch's + * chain to connect to the heads it already knew. + */ +class ConcordRollbackFloorTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun sessionRefusesAMetadataRollbackAcrossARefounding() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + + // Epoch 0: genesis metadata (v0 "Nostrichs") plus an owner rename (v1 "Nostrichs HQ"). + val genesisEditions = ConcordActions.controlEditions(community.genesisWraps, community.controlPlane) + val rename = + ConcordModeration.editMetadata( + actor = owner, + controlPlane = community.controlPlane, + communityId = community.communityId, + metadata = MetadataEntity(name = "Nostrichs HQ"), + current = genesisEditions, + createdAt = 2L, + ) + val epoch0Wraps = community.genesisWraps + rename + + // The rotator refounds, but compacts from the genesis subset ONLY — the rename (v1) is + // silently dropped. Every wrap it publishes is a genuine, owner-signed edition. + val newRoot = ByteArray(32) { 0x33 } + val newEpoch = community.rootEpoch + 1 + val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch) + val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl) + + val entry = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey())), + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val session = ConcordCommunitySession(entry, owner.pubKey) + + // The prior epoch's Control Plane is subscribed and AUTHed for — that is where the floor + // comes from, and without it the client has no memory to check the rotator against. + assertTrue( + session.historicalControlPlaneAddresses().contains(community.controlPlane.publicKeyHex), + "prior-epoch control plane not subscribed", + ) + assertTrue( + session.streamKeys().any { it.publicKeyHex == community.controlPlane.publicKeyHex }, + "prior-epoch control plane not AUTHed", + ) + + // Feed the rolled-back new epoch first, then the prior epoch drains in. + rolledBack.forEach { session.ingest(it) } + epoch0Wraps.forEach { session.ingest(it) } + + assertEquals( + "Nostrichs HQ", + session.state.value + ?.metadata + ?.name, + "the rollback to v0 must be refused", + ) + } + + @Test + fun sessionAdoptsAnHonestCompaction() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val genesisEditions = ConcordActions.controlEditions(community.genesisWraps, community.controlPlane) + val rename = + ConcordModeration.editMetadata( + actor = owner, + controlPlane = community.controlPlane, + communityId = community.communityId, + metadata = MetadataEntity(name = "Nostrichs HQ"), + current = genesisEditions, + createdAt = 2L, + ) + val epoch0Wraps = community.genesisWraps + rename + + val newRoot = ByteArray(32) { 0x33 } + val newEpoch = community.rootEpoch + 1 + val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch) + // Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives. + val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl) + + val entry = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey())), + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val session = ConcordCommunitySession(entry, owner.pubKey) + epoch0Wraps.forEach { session.ingest(it) } + honest.forEach { session.ingest(it) } + + val state = session.state.value + assertEquals("Nostrichs HQ", state?.metadata?.name) + assertTrue(state!!.channels.containsKey(community.generalChannelIdHex), "#general must survive an honest compaction") + } + + /** + * A fresh joiner holds no prior root, so it holds no floor: the dangling compacted head IS its + * baseline (CORD-04 §1). The floor must never regress this — that regression is what hid a + * refounded community's name, icon and channels. + */ + @Test + fun freshJoinerWithNoHeldRootsStillFoldsACompactedPlane() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val newRoot = ByteArray(32) { 0x33 } + val newEpoch = community.rootEpoch + 1 + val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch) + val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl) + + val entry = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val session = ConcordCommunitySession(entry, owner.pubKey) + assertTrue(session.historicalControlPlaneAddresses().isEmpty()) + compacted.forEach { session.ingest(it) } + + assertEquals( + "Nostrichs", + session.state.value + ?.metadata + ?.name, + ) + assertTrue( + session.state.value!! + .channels + .containsKey(community.generalChannelIdHex), + ) + } + + /** + * The floor is only as trustworthy as the editions it is built from. Any ex-member still holds + * a rotated-out root and could mint a high-version edition on that old Control Plane; if the + * floor were taken from an ungated fold, that would freeze the entity for every honest client. + * [ConcordCommunityState.authorizedHeads] gates the same way the live fold does, so an + * unprivileged author raises no floor. + */ + @Test + fun anUnprivilegedEditionOnAnOldPlaneRaisesNoFloor() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val rogue = NostrSignerInternal(KeyPair()) + val genesisEditions = ConcordActions.controlEditions(community.genesisWraps, community.controlPlane) + + // The rogue holds the (rotated-out) root, so it can publish a well-formed v1 metadata + // edition — it just has no MANAGE_METADATA and no owner-rooted grant. + val rogueEdit = + ConcordModeration.editMetadata( + actor = rogue, + controlPlane = community.controlPlane, + communityId = community.communityId, + metadata = MetadataEntity(name = "Hijacked"), + current = genesisEditions, + createdAt = 2L, + ) + + val floors = + ConcordCommunityState.authorizedHeads( + ConcordActions.controlEditions(community.genesisWraps + rogueEdit, community.controlPlane), + community.ownerPubKey, + ) + + val metadataFloor = floors[community.communityIdHex] + assertEquals(0L, metadataFloor?.version, "an unauthorized edition must not raise the floor") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index 83a44f55ee..98a54f0150 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -27,8 +27,10 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold +import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord04Roles.asFloor /** A channel id paired with its current folded definition. */ class ConcordChannel( @@ -56,11 +58,69 @@ class ConcordCommunityState( val dissolved: Boolean, ) { companion object { + /** + * The permission bit an edition of each entity kind must be authored under. + * `null` means owner-only (no bit grants it). Mirrors the per-kind gating + * [fold] applies before the structural fold. + */ + private fun requiredPermission(kind: ControlEntityKind): Int? = + when (kind) { + ControlEntityKind.METADATA -> ConcordPermissions.MANAGE_METADATA + ControlEntityKind.CHANNEL -> ConcordPermissions.MANAGE_CHANNELS + ControlEntityKind.ROLE, ControlEntityKind.GRANT -> ConcordPermissions.MANAGE_ROLES + ControlEntityKind.BANLIST -> ConcordPermissions.BAN + ControlEntityKind.INVITE_LIVE, ControlEntityKind.INVITE_REGISTRY, ControlEntityKind.INVITE_REVOKED -> ConcordPermissions.CREATE_INVITE + ControlEntityKind.DISSOLVED -> null + } + + /** + * The authority-gated structural head of **every** control entity, keyed by + * [ControlEdition.entityIdHex] — the source of the anti-rollback [EntityFloor]s a + * client carries across a CORD-06 Refounding. + * + * It is deliberately gated the same way [fold] gates each entity kind (and, for + * [ControlEntityKind.DISSOLVED], owner-only): an *ungated* head map would let any + * ex-member who still holds a rotated-out root mint a high-version edition on the + * old Control Plane and thereby raise our floor, freezing the entity for us. The + * floor must only ever remember editions we would actually have honored. + */ + fun authorizedHeads( + editions: Collection, + ownerPubKey: String, + floors: Map = emptyMap(), + ): Map { + val pool = EditionFold.admissible(editions, floors) + val authority = AuthorityResolver.resolve(pool, ownerPubKey) + val out = HashMap(floors) + for ((kind, list) in pool.groupBy { it.entityKind }) { + val bit = requiredPermission(kind) + val gated = + list.filter { + authority.isOwner(it.author) || (bit != null && authority.hasPermission(it.author, bit)) + } + for ((entity, head) in EditionFold.fold(gated, floors)) { + // Monotonic: a floor only ever rises. Folding epoch by epoch, an entity the + // newer epoch never mentions keeps the version the older one reached. + val prior = out[entity] + if (prior == null || head.version >= prior.version) out[entity] = head.asFloor() + } + } + return out + } + fun fold( editions: Collection, ownerPubKey: String, + floors: Map = emptyMap(), ): ConcordCommunityState { - val heads = EditionFold.fold(editions).values + // Everything below folds a *derived* view of the same editions (the resolver's + // authority chains, the per-kind gated folds), so the anti-rollback floor is applied + // once, up front, on the shared pool: a rolled-back edition is never seen by any of + // them, and the head we already folded is re-seated so the entity keeps its state. + @Suppress("NAME_SHADOWING") + val editions = EditionFold.admissible(editions, floors) + + val heads = EditionFold.fold(editions, floors).values // Resolve authority from the FULL edition set (not the structural heads): the resolver // folds each role/grant chain through authorized editions only, so a rogue higher-version // edition can't supersede a legit one before authority is even judged. @@ -84,7 +144,7 @@ class ConcordCommunityState( // authorized editions, then take the highest-version head (guarding against strays). val metadata = EditionFold - .fold(editorsWith(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA)) + .fold(editorsWith(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA), floors) .values .maxByOrNull { it.version } ?.let { ConcordJson.decodeOrNull(it.content) } @@ -92,7 +152,7 @@ class ConcordCommunityState( // Channels are gated by MANAGE_CHANNELS. Fold each channel entity from its authorized // editions only, dropping the tombstoned ones. val channels = LinkedHashMap() - for (head in EditionFold.fold(editorsWith(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS)).values) { + for (head in EditionFold.fold(editorsWith(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS), floors).values) { val def = ConcordJson.decodeOrNull(head.content) ?: continue if (def.deleted) continue channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt index 3b8e3ff2ea..dc2a898e9f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt @@ -21,6 +21,43 @@ package com.vitorpamplona.quartz.concord.cord04Roles import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Log + +/** + * The anti-rollback floor for one Control Plane entity: the [version] and + * [hashHex] of an edition this client already folded to, plus (when we still hold + * it) that [known] edition itself. + * + * A CORD-06 Refounding compacts the Control Plane by re-wrapping **one edition per + * entity** at the new epoch, and the *rotator* picks which one. Nothing in a + * signature stops it from re-wrapping version 1 of a chain that had reached + * version 2 — restoring a revoked role, clearing a banlist, reverting metadata — + * because every edition it serves is genuine. This is rollback by omission, and + * the only defense is memory: a client that already folded to v2 must refuse to + * come back down. [EntityFloor] is that memory, and [EditionFold.foldEntity] / + * [EditionFold.admissible] are where it is enforced. + * + * [known] is what "keeps its existing state" means concretely: when the offered + * chain cannot be connected to the floor, we fall back to the edition we last + * folded rather than letting the entity vanish (an entity vanishing from the fold + * is itself a rollback — a dropped banlist is an unban). + */ +class EntityFloor( + val version: Long, + val hashHex: String, + val known: ControlEdition? = null, +) + +/** This edition as an anti-rollback floor for its entity. */ +fun ControlEdition.asFloor(): EntityFloor = EntityFloor(version, hashHex, this) + +/** + * Called when an entity's offered chain cannot be connected to the floor this + * client already holds — i.e. someone tried to move the entity backwards. The + * arguments are the entity id, the floor version we refuse to drop below, and the + * highest version offered. + */ +typealias GapReporter = (entityIdHex: String, floorVersion: Long, offeredVersion: Long) -> Unit /** * Folds Control Plane editions into the current head of each entity (CORD-04 @@ -29,7 +66,14 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey * Rules enforced here: * - **Genesis anchoring** — a chain starts at the lowest-version edition with no * `ep` (prev hash). - * - **Refounding fallback (fresh joiner)** — when no genesis is present, anchor + * - **Anti-rollback floor** — when the caller supplies an [EntityFloor] (an + * entity head it already folded), the walk is *anchored at that floor*: it must + * find the exact edition (version + hash) it already knew. If it cannot, the + * offered chain is a **gap** and nothing above the floor is adopted — the entity + * keeps [EntityFloor.known] instead. This is what stops a Refounding rotator + * from serving v1 of a chain that had reached v2 (see [EntityFloor]). + * - **Refounding fallback (fresh joiner)** — when no genesis is present *and no + * floor is held*, anchor * at the lowest-version edition available and accept it as the baseline. After * a Refounding (CORD-06 §3) the compacted head still carries the `ep` it had * before compaction, citing an edition in the *prior* epoch that a fresh joiner @@ -51,36 +95,86 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey * this structural fold; this class is purely the chain walk. */ object EditionFold { - /** Groups mixed [editions] by entity id and folds each to its head. */ - fun fold(editions: Collection): Map { + private const val TAG = "ConcordEditionFold" + + /** + * The default [GapReporter]: a rollback refusal is security-relevant (a rotator + * tried to revert an entity), so it is warned, never swallowed. + */ + val LOG_GAP: GapReporter = { entityIdHex, floorVersion, offeredVersion -> + Log.w(TAG) { + "Control-plane rollback refused for entity $entityIdHex: already folded v$floorVersion, offered chain tops out at v$offeredVersion and does not connect to it" + } + } + + /** + * Groups mixed [editions] by entity id and folds each to its head, honoring the + * per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]). + * + * Only entities actually present in [editions] are folded — re-seating an entity + * that was omitted entirely (the cheapest rollback of all) is [admissible]'s job, + * because it runs once on the whole pool, while this is also called on per-kind + * subsets that must not have other kinds' heads injected into them. + */ + fun fold( + editions: Collection, + floors: Map = emptyMap(), + onGap: GapReporter = LOG_GAP, + ): Map { val byEntity = editions.groupBy { it.entityIdHex } val out = HashMap(byEntity.size) for ((entity, list) in byEntity) { - foldEntity(list)?.let { out[entity] = it } + foldEntity(list, floors[entity], onGap)?.let { out[entity] = it } } return out } - /** Folds the editions of a single entity into its current head, or null. */ - fun foldEntity(editions: List): ControlEdition? { - if (editions.isEmpty()) return null + /** + * Folds the editions of a single entity into its current head, or null. + * + * With no [floor] this is the fresh-joiner fold: genesis-anchored, falling back + * to the lowest-version edition present (the compaction bootstrap). With a + * [floor] the walk is anchored at the exact edition already folded; if that + * edition is not among [editions] the chain is **gapped** and nothing above the + * floor is adopted — [EntityFloor.known] is kept instead (or null when we no + * longer hold it). A head is therefore never below the floor version. + */ + fun foldEntity( + editions: List, + floor: EntityFloor? = null, + onGap: GapReporter = LOG_GAP, + ): ControlEdition? { + if (editions.isEmpty()) return floor?.known // Index editions by version, keeping the tie-break winner where several // share a version (lower rumor id wins). val byVersion = HashMap>() for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e) - // Anchor at the genesis (lowest version with no prev hash), preferring the - // tie-break winner. When no genesis is present — the compacted head of a - // Refounded community carries a prev citing the prior epoch — a fresh joiner - // anchors at the lowest-version edition it does hold and accepts it as the - // baseline (CORD-04 §1 / CORD-06 §3). `editions` is non-empty here. var head = - editions - .filter { it.prevHash == null } - .minWithOrNull(compareBy({ it.version }, { it.rumorId })) - ?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId })) - ?: return null + if (floor != null) { + // Anchored at what we already folded: the offered set MUST contain that exact + // edition (same version AND same hash — a same-version sibling is a fork, not + // our chain). Failing that, refuse to move at all rather than accept an + // unverifiable jump; walking up from the floor also makes a head below the + // floor version structurally impossible. + editions.firstOrNull { it.version == floor.version && it.hashHex == floor.hashHex } + ?: run { + onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version }) + return floor.known + } + } else { + // Anchor at the genesis (lowest version with no prev hash), preferring the + // tie-break winner. When no genesis is present — the compacted head of a + // Refounded community carries a prev citing the prior epoch — a fresh joiner + // anchors at the lowest-version edition it does hold and accepts it as the + // baseline (CORD-04 §1 / CORD-06 §3). `editions` is non-empty here. + editions + .filter { it.prevHash == null } + .minWithOrNull(compareBy({ it.version }, { it.rumorId })) + ?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId })) + ?: return null + } // Walk the chain upward while the next version chains from the current head. while (true) { @@ -93,4 +187,49 @@ object EditionFold { } return head } + + /** + * The subset of [editions] a client holding [floors] may consider at all — the + * pre-filter for the layers that fold *derived* views of the same editions + * (authority resolution, per-kind gated folds) and therefore cannot each carry + * the floor themselves. + * + * Per entity: if the offered set contains the floor edition, the chain connects + * and everything is admissible. If it does not, the entity is **gapped** and + * every offered edition at or above the floor version is dropped, with + * [EntityFloor.known] substituted so the entity keeps the state we last folded. + * Entities with no floor pass through untouched (a fresh joiner must not be + * penalized for having no history). + */ + fun admissible( + editions: Collection, + floors: Map, + onGap: GapReporter = LOG_GAP, + ): List { + if (floors.isEmpty()) return editions.toList() + + val out = ArrayList(editions.size + floors.size) + val seen = HashSet(floors.size) + for ((entity, list) in editions.groupBy { it.entityIdHex }) { + seen.add(entity) + val floor = floors[entity] + if (floor == null) { + out.addAll(list) + continue + } + if (list.any { it.version == floor.version && it.hashHex == floor.hashHex }) { + out.addAll(list) + continue + } + onGap(entity, floor.version, list.maxOf { it.version }) + // Below the floor is history we already absorbed; at or above it is the jump we + // refuse. Re-seat the known head so the entity's state is kept, not cleared. + list.filterTo(out) { it.version < floor.version } + floor.known?.let { out.add(it) } + } + for ((entity, floor) in floors) { + if (entity !in seen) floor.known?.let { out.add(it) } + } + return out + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldFloorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldFloorTest.kt new file mode 100644 index 0000000000..186311dea5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldFloorTest.kt @@ -0,0 +1,174 @@ +/* + * 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.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Anti-rollback: a CORD-06 Refounding re-wraps ONE edition per entity at the new epoch and the + * *rotator* chooses which one, so it can serve v1 of a chain that had reached v2 — restoring a + * revoked role, clearing a banlist, reverting metadata — with every signature genuine. Rollback + * by omission, not forgery. A client that already folded to v2 must refuse to come back down. + */ +class EditionFoldFloorTest { + private val author = KeyPair().pubKey.toHexKey() + private val eid = ByteArray(32) { 0xAB.toByte() } + private val otherEid = ByteArray(32) { 0xCD.toByte() } + + private fun edition( + version: Long, + prevHash: ByteArray?, + content: String, + entityId: ByteArray = eid, + rumorId: String = "id-v$version", + ) = ControlEdition( + entityKind = ControlEntityKind.GRANT, + entityId = entityId, + version = version, + prevHash = prevHash, + authorityCitation = null, + content = content, + author = author, + rumorId = rumorId, + createdAt = 1_700_000_000L + version, + ) + + // A grant chain: v0 grants the role, v1 edits it, v2 revokes it. Rolling back to v1 restores + // a role the community already revoked. + private val v0 = edition(0, null, """{"member":"a","role_ids":["mod"]}""") + private val v1 = edition(1, v0.hash, """{"member":"a","role_ids":["admin"]}""") + private val v2 = edition(2, v1.hash, """{"member":"a","role_ids":[]}""") + private val v3 = edition(3, v2.hash, """{"member":"a","role_ids":["mod"]}""") + + private val floorAtV2 = v2.asFloor() + + // ---- foldEntity ---------------------------------------------------------- + + /** The attack: the rotator serves only v1, whose prev dangles (v0 was not re-wrapped). */ + @Test + fun refusesRollbackToAnOmittedLowerVersion() { + val gaps = mutableListOf>() + val head = EditionFold.foldEntity(listOf(v1), floorAtV2) { e, f, o -> gaps += Triple(e, f, o) } + + assertEquals(v2.rumorId, head?.rumorId, "the revoked-at-v2 head must survive the rollback") + assertEquals(listOf(Triple(v2.entityIdHex, 2L, 1L)), gaps, "the refusal must be reported") + } + + /** Even an intact prefix (v0→v1) is a downgrade when we already folded v2. */ + @Test + fun refusesRollbackEvenWhenTheOfferedPrefixIsIntact() { + val head = EditionFold.foldEntity(listOf(v0, v1), floorAtV2) { _, _, _ -> } + assertEquals(v2.rumorId, head?.rumorId) + } + + /** A same-version forgery is a fork, not our chain: the floor matches on hash, not version. */ + @Test + fun refusesSiblingAtTheFloorVersion() { + val forgedV2 = edition(2, v1.hash, """{"member":"a","role_ids":["owner-ish"]}""", rumorId = "id-forged") + val head = EditionFold.foldEntity(listOf(v1, forgedV2), floorAtV2) { _, _, _ -> } + assertEquals(v2.rumorId, head?.rumorId) + assertEquals(v2.content, head?.content) + } + + /** An honest compaction re-wraps the very head we hold: the walk connects, so it is adopted. */ + @Test + fun acceptsHonestCompactionAtTheFloor() { + val gaps = mutableListOf() + val head = EditionFold.foldEntity(listOf(v2), floorAtV2) { e, _, _ -> gaps += e } + assertEquals(v2.rumorId, head?.rumorId) + assertTrue(gaps.isEmpty(), "an honest compaction must not report a gap") + } + + /** And it advances past the floor when the new epoch chains forward from it. */ + @Test + fun advancesAboveTheFloorWhenTheChainConnects() { + val head = EditionFold.foldEntity(listOf(v3, v2), floorAtV2) { _, _, _ -> } + assertEquals(v3.rumorId, head?.rumorId) + } + + /** A floor whose known edition we no longer hold still refuses to move down — it adopts nothing. */ + @Test + fun refusesRollbackWithNoKnownEditionToFallBackOn() { + val hashOnlyFloor = EntityFloor(v2.version, v2.hashHex, known = null) + assertNull(EditionFold.foldEntity(listOf(v1), hashOnlyFloor) { _, _, _ -> }) + } + + /** + * A fresh joiner holds no floor and MUST still accept a dangling compacted head as its + * baseline (CORD-04 §1 / CORD-06 §3) — it legitimately has no history to fail closed on. + * Regression guard for `ControlEditionTest.foldWithoutGenesisAcceptsCompactedHead`. + */ + @Test + fun freshJoinerWithoutAFloorStillAcceptsADanglingCompactedHead() { + assertEquals(v1.rumorId, EditionFold.foldEntity(listOf(v1), floor = null)?.rumorId) + } + + // ---- fold (per-entity map) ----------------------------------------------- + + @Test + fun foldAppliesTheFloorPerEntity() { + val otherV0 = edition(0, null, """{"member":"b","role_ids":["mod"]}""", entityId = otherEid, rumorId = "other-v0") + val heads = EditionFold.fold(listOf(v1, otherV0), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> } + + assertEquals(v2.rumorId, heads[v2.entityIdHex]?.rumorId, "floored entity refuses the rollback") + assertEquals(otherV0.rumorId, heads[otherV0.entityIdHex]?.rumorId, "un-floored entity folds normally") + } + + // ---- admissible (the pre-filter the derived folds share) ------------------ + + /** A gapped entity loses every edition at or above the floor, and keeps the head we knew. */ + @Test + fun admissibleDropsTheRolledBackChainAndReSeatsTheKnownHead() { + val forgedV2 = edition(2, v1.hash, """{"member":"a","role_ids":["admin"]}""", rumorId = "id-forged") + val admissible = EditionFold.admissible(listOf(v0, v1, forgedV2), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> } + + assertContentEquals(listOf(v0.rumorId, v1.rumorId, v2.rumorId), admissible.map { it.rumorId }) + } + + /** When the chain connects, nothing is filtered — the new epoch may legitimately advance. */ + @Test + fun admissiblePassesEverythingThroughWhenTheChainConnects() { + val admissible = EditionFold.admissible(listOf(v2, v3), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> } + assertContentEquals(listOf(v2.rumorId, v3.rumorId), admissible.map { it.rumorId }) + } + + /** Omitting an entity outright is the cheapest rollback of all (a dropped banlist is an unban). */ + @Test + fun admissibleKeepsAnEntityThatWasOmittedEntirely() { + val otherV0 = edition(0, null, """{"member":"b","role_ids":["mod"]}""", entityId = otherEid, rumorId = "other-v0") + val admissible = EditionFold.admissible(listOf(otherV0), mapOf(v2.entityIdHex to floorAtV2)) { _, _, _ -> } + + assertContentEquals(listOf(otherV0.rumorId, v2.rumorId), admissible.map { it.rumorId }) + } + + /** No floors at all → the fresh-joiner path, untouched. */ + @Test + fun admissibleIsAPassThroughWithoutFloors() { + val admissible = EditionFold.admissible(listOf(v1), emptyMap()) + assertContentEquals(listOf(v1.rumorId), admissible.map { it.rumorId }) + } +}