fix(concord): keep a private channel's retired keys, so its history survives a rotation

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 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-30 08:25:58 -04:00
co-authored by Claude Opus 5.5
parent ff7716f6a6
commit b106394b67
3 changed files with 77 additions and 12 deletions
@@ -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
}
@@ -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<SteppedChannelKey> = 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<SteppedChannelKey> = 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<Triple<HexKey, Long, Long>>,
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)))
}
/**
@@ -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)
}
}