From b106394b67565664b7d9f0fd769ce3bb9fa3f90d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 30 Sep 2026 08:25:58 -0400 Subject: [PATCH] fix(concord): keep a private channel's retired keys, so its history survives a rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rotation replaced the channel's current key in the List and dropped the old one. `seed` only anchors keys held at join, so a channel created or privatised later had nowhere else to keep them: after the next restart the rotator (and every member that adopted the rotation) lost all history from before it. Seen on RK Test: #p came back empty after a Rotate key. A rotation now appends the key it replaces to the channel's `priors` (`{key, epoch, retired_at}`, the reference client's shape, which we already read), plus any intermediate keys an adoption walk stepped over — the walk computed them with their publish times and then threw them away. This leaves CORD-02 §8's preference to keep intermediate keys out of the List: re-walking from `seed` cannot reach a channel that isn't in `seed`. Co-Authored-By: Claude Opus 5.5 --- .../commons/actions/ConcordPrivateChannels.kt | 2 +- .../cord03Channels/ConcordChannelKeyring.kt | 57 ++++++++++++++++--- .../ConcordChannelKeyringTest.kt | 30 ++++++++-- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannels.kt index 7cdd4bd189..f521909b91 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannels.kt @@ -377,7 +377,7 @@ object ConcordPrivateChannels { if (held.epoch != fromEpochs[id]) continue next = when (outcome) { - is ChannelRekeyOutcome.Adopted -> ConcordChannelKeyring.withRotatedKey(next, id, outcome.key.toHexKey(), outcome.epoch) ?: next + is ChannelRekeyOutcome.Adopted -> ConcordChannelKeyring.withRotatedKey(next, id, outcome.key.toHexKey(), outcome.epoch, steppedOver = outcome.steppedOver) ?: next is ChannelRekeyOutcome.Removed -> ConcordChannelKeyring.withoutChannel(next, id, outcome.epoch) ChannelRekeyOutcome.None -> next } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt index 0df4cd0898..bdedd2b988 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt @@ -23,7 +23,10 @@ package com.vitorpamplona.quartz.concord.cord03Channels import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordEntryResidue import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord06Rekey.SteppedChannelKey import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -43,11 +46,12 @@ class HistoricalChannelKey( * - **Current keys** are the entry's `channels` (`privateChannels`): exactly one per channel, the * newest epoch held. A rotation replaces it in place, keeping any unknown keys another client * wrote inside the channel object (the round-trip rule, CORD-02 §6/§8). - * - **Older keys** are never written by this client: CORD-02 §8 keeps intermediate keys out of the - * List ("a client's own optimisation … it does not belong in the List"). They are still *read* - * wherever they already are — the entry's `seed` snapshot (the earliest epoch held, the backfill - * anchor) and the reference client's `priors` extension inside a channel object — so history - * written before a rotation stays readable. + * - **Older keys** are read from the entry's `seed` snapshot (the earliest epoch held, the backfill + * anchor) and from the reference client's `priors` extension inside a channel object, and a + * rotation writes the key it replaces into `priors` (`{key, epoch, retired_at}`, as Armada + * does). CORD-02 §8 would rather keep intermediate keys out of the List and re-walk rotations + * from `seed`, but a channel created or privatised after the join has no `seed` key to walk + * from: without `priors` its pre-rotation history was unreadable after the next restart. * - **Cuts** are the reference client's `channel_cuts` extension on the entry: per channel, the * channel epoch whose rotation cut this member out. A floor, never rolled back: a key below it is * refused, so a stale bundle or catch-up cannot quietly restore revoked access. Kept as the raw @@ -118,18 +122,27 @@ object ConcordChannelKeyring { fun withChannelKey( entry: ConcordCommunityListEntry, key: PrivateChannelKey, + retiredAt: Long = TimeUtils.now(), + steppedOver: List = emptyList(), ): ConcordCommunityListEntry? { if (!HEX64.matches(key.key) || !HEX64.matches(key.channelId)) return null if (isCutOff(entry, key.channelId, key.epoch)) return null val held = entry.privateChannels.firstOrNull { it.channelId.equals(key.channelId, ignoreCase = true) } if (held != null && HEX64.matches(held.key) && held.epoch >= key.epoch) return null + val retired = + // A walk's stepped keys carry when their rotation was published, so they win the dedupe + // over the held key's local "now". + buildList { + steppedOver.forEach { add(Triple(it.key.toHexKey(), it.epoch, it.retiredAt)) } + if (held != null && HEX64.matches(held.key)) add(Triple(held.key, held.epoch, retiredAt)) + } val next = PrivateChannelKey( channelId = key.channelId.lowercase(), key = key.key.lowercase(), epoch = key.epoch, name = key.name.ifBlank { held?.name ?: "" }, - extras = held?.extras ?: key.extras, + extras = withPriors(held?.extras ?: key.extras, retired, key.key), ) return entry.copyChannels(entry.privateChannels.filterNot { it.channelId.equals(key.channelId, ignoreCase = true) } + next, entry.residue) } @@ -140,9 +153,39 @@ object ConcordChannelKeyring { channelIdHex: HexKey, newKeyHex: HexKey, newEpoch: Long, + retiredAt: Long = TimeUtils.now(), + steppedOver: List = emptyList(), ): ConcordCommunityListEntry? { val held = heldKey(entry, channelIdHex) ?: return null - return withChannelKey(entry, PrivateChannelKey(held.channelId, newKeyHex, newEpoch, held.name, held.extras)) + return withChannelKey(entry, PrivateChannelKey(held.channelId, newKeyHex, newEpoch, held.name, held.extras), retiredAt, steppedOver) + } + + /** + * [extras] with each [retired] `(key, epoch, retired_at)` appended to `priors`, skipping one + * already there at that epoch and key, and never [currentKey] itself. Unknown fields inside + * existing prior objects ride through. + */ + private fun withPriors( + extras: JsonObject, + retired: List>, + currentKey: HexKey, + ): JsonObject { + val existing = (extras[PRIORS] as? JsonArray)?.toList() ?: emptyList() + val seen = + existing.mapNotNullTo(HashSet()) { p -> + val obj = p as? JsonObject ?: return@mapNotNullTo null + val k = (obj["key"] as? JsonPrimitive)?.contentOrNull?.lowercase() ?: return@mapNotNullTo null + val e = (obj["epoch"] as? JsonPrimitive)?.longOrNull ?: return@mapNotNullTo null + e to k + } + val added = + retired.mapNotNull { (key, epoch, at) -> + val k = key.lowercase() + if (!HEX64.matches(k) || k == currentKey.lowercase() || !seen.add(epoch to k)) return@mapNotNull null + JsonObject(mapOf("key" to JsonPrimitive(k), "epoch" to JsonPrimitive(epoch), "retired_at" to JsonPrimitive(at))) + } + if (added.isEmpty()) return extras + return JsonObject(extras + (PRIORS to JsonArray(existing + added))) } /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt index e567776c13..82c2dd4087 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord06Rekey.SteppedChannelKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -121,7 +123,7 @@ class ConcordChannelKeyringTest { } @Test - fun olderKeysAreReadFromSeedAndPriorsButNeverWritten() { + fun olderKeysAreReadFromSeedAndPriors() { val wire = """{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1, "seed":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}", @@ -135,10 +137,30 @@ class ConcordChannelKeyringTest { assertEquals(10, ConcordChannelKeyring.nextChannelEpoch(held, chan, observedFloor = 9)) assertEquals(1, ConcordChannelKeyring.nextChannelEpoch(held, other)) - // A rotation we launch adds no prior of its own (CORD-02 §8 keeps intermediate keys out of the List). - val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(held, chan, "13".repeat(32), 3)) + // A rotation keeps the key it replaces as a prior; the new current key is never one. + val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(held, chan, "13".repeat(32), 3, retiredAt = 7)) val priors = rotated.privateChannels.single().extras[ConcordChannelKeyring.PRIORS] as JsonArray - assertEquals(1, priors.size) + assertEquals(2, priors.size) + assertEquals(JsonPrimitive(7L), priors.last().jsonObject["retired_at"]) assertFalse(ConcordChannelKeyring.historicalKeys(rotated, chan).any { it.key == "13".repeat(32) }) } + + @Test + fun aRotationKeepsEveryEarlierKeyReadableAfterARoundTrip() { + // A channel created after the join has no `seed` anchor: the List is the only place its + // older keys can live, so a rotation that dropped them lost that history on the next restart. + val held = entry(listOf(PrivateChannelKey(chan, k0, 0, "mods"))) + val one = roundTrip(assertNotNull(ConcordChannelKeyring.withRotatedKey(held, chan, k1, 1, retiredAt = 10))) + // An adoption that walked over epoch 2 keeps the skipped key too. + val three = + roundTrip( + assertNotNull( + ConcordChannelKeyring.withRotatedKey(one, chan, "13".repeat(32), 3, retiredAt = 30, steppedOver = listOf(SteppedChannelKey(k2.hexToByteArray(), 2, 20))), + ), + ) + assertEquals(listOf(2L to k2, 1L to k1, 0L to k0), ConcordChannelKeyring.historicalKeys(three, chan).map { it.epoch to it.key }) + // Re-adding a prior already recorded does not duplicate it. + val again = assertNotNull(ConcordChannelKeyring.withRotatedKey(three, chan, "14".repeat(32), 4, retiredAt = 40, steppedOver = listOf(SteppedChannelKey(k2.hexToByteArray(), 2, 20)))) + assertEquals(4, (again.privateChannels.single().extras[ConcordChannelKeyring.PRIORS] as JsonArray).size) + } }