mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
co-authored by
Claude Opus 5.5
parent
ff7716f6a6
commit
b106394b67
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
+50
-7
@@ -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)))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+26
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user