mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Merge pull request #4262 from vitorpamplona/claude/hopeful-brown-1suxdw
Implement CORD spec conformance: fragments, pins, rekey, dissolution
This commit is contained in:
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
|
||||
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -965,6 +966,11 @@ object LocalPreferences {
|
||||
LatestEventSlot.EPHEMERAL_LIST to settings.backupEphemeralChatList?.let { OptimizedJsonMapper.toJson(it) },
|
||||
LatestEventSlot.RELAY_GROUP_LIST to settings.backupRelayGroupList?.let { OptimizedJsonMapper.toJson(it) },
|
||||
LatestEventSlot.CONCORD_LIST to settings.backupConcordList?.let { OptimizedJsonMapper.toJson(it) },
|
||||
// Event JSON never holds a raw newline, so one event per line is unambiguous.
|
||||
LatestEventSlot.CONCORD_LIST_FRAGMENTS to
|
||||
settings.backupConcordListFragments
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.joinToString("\n") { OptimizedJsonMapper.toJson(it) },
|
||||
LatestEventSlot.TRUST_PROVIDER_LIST to settings.backupTrustProviderList?.let { OptimizedJsonMapper.toJson(it) },
|
||||
LatestEventSlot.KEY_PACKAGE_RELAY_LIST to settings.backupKeyPackageRelayList?.let { OptimizedJsonMapper.toJson(it) },
|
||||
LatestEventSlot.FAVORITE_ALGO_FEEDS_LIST to settings.backupFavoriteAlgoFeedsList?.let { OptimizedJsonMapper.toJson(it) },
|
||||
@@ -1457,6 +1463,7 @@ object LocalPreferences {
|
||||
backupEphemeralChatList = latestEphemeralListResolved,
|
||||
backupRelayGroupList = latestRelayGroupListResolved,
|
||||
backupConcordList = latestConcordListResolved,
|
||||
backupConcordListFragments = parseConcordListFragments(stores.latestEvents[LatestEventSlot.CONCORD_LIST_FRAGMENTS]),
|
||||
backupTrustProviderList = latestTrustProviderListResolved,
|
||||
backupKeyPackageRelayList = latestKeyPackageRelayListResolved,
|
||||
backupFavoriteAlgoFeedsList = latestFavoriteAlgoFeedsListResolved,
|
||||
@@ -1623,6 +1630,16 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The saved kind-33302 Community List fragments, one event JSON per line. Kept out of the
|
||||
* account loader's coroutine body, which already sits at the JVM's method-size limit.
|
||||
*/
|
||||
private fun parseConcordListFragments(value: String?): List<ConcordCommunityListFragmentEvent> =
|
||||
value
|
||||
?.split('\n')
|
||||
?.mapNotNull { parseEventOrNull<ConcordCommunityListFragmentEvent>(it) }
|
||||
.orEmpty()
|
||||
|
||||
private inline fun <reified T> parseEventOrNull(value: String?): T? {
|
||||
if (value.isNullOrEmpty() || value == "null") {
|
||||
return null
|
||||
|
||||
+4
-3
@@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -278,15 +279,15 @@ fun ChatMessageActionSheet(
|
||||
|
||||
// Editing my own chat message. Two surfaces publish an edit today, gated by type:
|
||||
// - Buzz: kind-40002 stream message → a kind-40003 edit.
|
||||
// - Concord: kind-9 channel message (carries a ConcordChannel gatherer) → a
|
||||
// kind-1010 edit wrapped on the channel plane.
|
||||
// - Concord: kind-9 channel message or kind-1111 thread reply (carries a
|
||||
// ConcordChannel gatherer) → a kind-3302 edit wrapped on the channel plane.
|
||||
// Both restrict to my own messages; a note is only ever one of the two, so at
|
||||
// most one tile shows and both route through the same edit callback.
|
||||
val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
|
||||
val canEditBuzz = onWantsToEditChatMessage != null && note.event is StreamMessageV2Event && isMine
|
||||
val canEditConcord =
|
||||
onWantsToEditChatMessage != null &&
|
||||
note.event is ChatEvent &&
|
||||
(note.event is ChatEvent || note.event is CommentEvent) &&
|
||||
isMine &&
|
||||
note.inGatherers?.any { it is ConcordChannel } == true
|
||||
// Marmot: my own text message in a group -> a kind-1009 edit inside the group. A
|
||||
|
||||
+3
-12
@@ -399,12 +399,8 @@ fun ConcordChannelListScreen(
|
||||
items(channels, key = { it.key }) { entry ->
|
||||
val def = entry.value.definition
|
||||
val name = def.name.ifBlank { entry.key }
|
||||
val icon =
|
||||
when {
|
||||
def.voice == true -> MaterialSymbols.Mic
|
||||
def.private == true -> MaterialSymbols.Lock
|
||||
else -> MaterialSymbols.Tag
|
||||
}
|
||||
// Every Channel is callable (CORD-07), so there is no voice-only icon.
|
||||
val icon = if (def.private) MaterialSymbols.Lock else MaterialSymbols.Tag
|
||||
val typingAuthors =
|
||||
remember(typingMap, typingNow, entry.key) {
|
||||
(typingMap[entry.key] ?: emptyMap())
|
||||
@@ -417,7 +413,6 @@ fun ConcordChannelListScreen(
|
||||
channelKey = entry.key,
|
||||
channelName = name,
|
||||
icon = icon,
|
||||
isVoice = def.voice == true,
|
||||
typingAuthors = typingAuthors,
|
||||
canManageChannels = canManageChannels,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -445,7 +440,6 @@ private fun ConcordChannelListRow(
|
||||
channelKey: String,
|
||||
channelName: String,
|
||||
icon: MaterialSymbol,
|
||||
isVoice: Boolean,
|
||||
typingAuthors: List<HexKey>,
|
||||
canManageChannels: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -506,7 +500,7 @@ private fun ConcordChannelListRow(
|
||||
// Line 2: the last-message preview (or a live "typing…"), then the unread-message badge.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
|
||||
ConcordChannelPreviewLine(lastNote, typingAuthors, accountViewModel)
|
||||
}
|
||||
ConcordUnreadBadge(unread)
|
||||
}
|
||||
@@ -535,7 +529,6 @@ private val FAB_CLEARANCE = 96.dp
|
||||
@Composable
|
||||
private fun ConcordChannelPreviewLine(
|
||||
lastNote: Note?,
|
||||
isVoice: Boolean,
|
||||
typingAuthors: List<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
@@ -572,8 +565,6 @@ private fun ConcordChannelPreviewLine(
|
||||
} else if (event != null) {
|
||||
event.content.take(80)
|
||||
} else {
|
||||
// Voice channels never carry chat notes, so "No messages yet" would read oddly — leave blank.
|
||||
if (isVoice) return
|
||||
stringRes(Res.string.concord_channel_no_messages)
|
||||
}
|
||||
Text(
|
||||
|
||||
+12
-2
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.back
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_private_channel_no_key
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_send_image_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_typing_many
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_typing_one
|
||||
@@ -123,6 +124,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
|
||||
|
||||
/**
|
||||
@@ -264,6 +266,10 @@ fun ConcordChannelScreen(
|
||||
// CORD-02 §9: an owner-signed tombstone seals the community read-only — the composer is
|
||||
// gone (canPost() is false) and this replaces it so the seal is explained, not silent.
|
||||
ConcordDissolvedNotice()
|
||||
} else if (!channel.keyHeld) {
|
||||
// CORD-03 §1: a Private Channel is keyed independently; without its key there is no
|
||||
// plane only its members can read, so nothing may be posted (never to the root plane).
|
||||
ConcordReadOnlyNotice(Res.string.concord_private_channel_no_key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,9 +280,13 @@ fun ConcordChannelScreen(
|
||||
* The tombstone seals the community: history stays readable, but no member may post again.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordDissolvedNotice() {
|
||||
private fun ConcordDissolvedNotice() = ConcordReadOnlyNotice(Res.string.concord_dissolved_read_only)
|
||||
|
||||
/** A one-line explanation shown where the composer would be when this channel cannot be posted to. */
|
||||
@Composable
|
||||
private fun ConcordReadOnlyNotice(message: StringResource) {
|
||||
Text(
|
||||
text = stringRes(Res.string.concord_dissolved_read_only),
|
||||
text = stringRes(message),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
|
||||
+1
-6
@@ -224,12 +224,7 @@ fun ConcordHomeScreen(
|
||||
communityId = entry.id,
|
||||
channelKey = ch.key,
|
||||
channelName = def.name.ifBlank { ch.key },
|
||||
icon =
|
||||
when {
|
||||
def.voice == true -> MaterialSymbols.Mic
|
||||
def.private == true -> MaterialSymbols.Lock
|
||||
else -> MaterialSymbols.Tag
|
||||
},
|
||||
icon = if (def.private) MaterialSymbols.Lock else MaterialSymbols.Tag,
|
||||
hideIfRead = mode == ChannelExpand.UNREAD,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { nav.nav(Route.Concord(entry.id, ch.key)) },
|
||||
|
||||
+1
-6
@@ -685,12 +685,7 @@ private fun ConcordServerPickerGroup(
|
||||
channels.forEach { channel ->
|
||||
val entry = BottomBarEntry.ConcordChannel(community.id, channel.channelIdHex, community.relays)
|
||||
val def = channel.definition
|
||||
val icon =
|
||||
when {
|
||||
def.voice -> MaterialSymbols.Mic
|
||||
def.private -> MaterialSymbols.Lock
|
||||
else -> MaterialSymbols.Tag
|
||||
}
|
||||
val icon = if (def.private) MaterialSymbols.Lock else MaterialSymbols.Tag
|
||||
AvailableRow(
|
||||
leading = { LeadingGlyph(icon) },
|
||||
label = def.name.ifBlank { channel.channelIdHex.take(8) },
|
||||
|
||||
+13
-8
@@ -668,23 +668,28 @@ author** — every 46010 gate names its approver in a `p` tag — and matched to
|
||||
|
||||
Encrypted, serverless communities (the CORD specs). Community secrets
|
||||
persist in `~/.amy/<account>/concord.json`; your joined-community list is
|
||||
also carried on-relay as an encrypted kind:13302.
|
||||
also carried on-relay as the encrypted, fragmented kind:33302 Community List
|
||||
(CORD-02 §8; the retired kind:13302 is still read on import).
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy concord create --name NAME [--about T] [--relay wss://a,wss://b]` | Create an encrypted Concord community. `--relay` is canonical; `--relays` is accepted as an alias. |
|
||||
| `amy concord list` | List joined Concord communities. |
|
||||
| `amy concord import` | Fetch + decrypt this account's kind:13302 community list (carries heldRoots, CORD-06). |
|
||||
| `amy concord channels COMMUNITY` | List a community's channels. |
|
||||
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). |
|
||||
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane. |
|
||||
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. |
|
||||
| `amy concord import` | Fetch + decrypt this account's Community List — the kind:33302 fragments plus the retired kind:13302 (carries heldRoots, CORD-06). |
|
||||
| `amy concord channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). |
|
||||
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. |
|
||||
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). |
|
||||
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator). |
|
||||
| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. |
|
||||
| `amy concord join URL` | Redeem an invite link and save the community. |
|
||||
| `amy concord join URL` | Redeem an invite link, save the community, and publish a Guestbook Join echoing the link's attribution (CORD-05 §1/§6). |
|
||||
| `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). |
|
||||
| `amy concord recover [COMMUNITY] [--rejoin]` | Report whether a Refounding left us behind (our joined-through link resolves to a higher epoch). A bundle never moves the base on its own (CORD-06 §2); `--rejoin` explicitly re-accepts the link. Ban-gated, fails closed. |
|
||||
| `amy concord refound COMMUNITY --remove U[,U…]` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after; reserves its keys so a re-run resumes with the same root; refused for a dissolved community. |
|
||||
| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). |
|
||||
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`). |
|
||||
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`; also `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `MANAGE_MESSAGES`, `CREATE_INVITE`, `VIEW_AUDIT_LOG`, `MENTION_EVERYONE`, `PIN_MESSAGES`). |
|
||||
| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. |
|
||||
| `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. |
|
||||
| `amy concord dissolve COMMUNITY --yes` | Owner only, irreversible: publish the `eid`-bound dissolution tombstone that seals the community read-only (CORD-02 §9). |
|
||||
|
||||
### cordn (MLS over an MCP coordinator)
|
||||
|
||||
|
||||
+48
-12
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -56,7 +57,13 @@ object ConcordChannelCommands {
|
||||
"banner" to state.metadata?.banner?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) },
|
||||
"channels" to
|
||||
state.channels.values.map {
|
||||
mapOf("id" to it.channelIdHex, "name" to it.definition.name, "voice" to it.definition.voice, "private" to it.definition.private)
|
||||
mapOf(
|
||||
"id" to it.channelIdHex,
|
||||
"name" to it.definition.name,
|
||||
"private" to it.definition.private,
|
||||
// False for a Private Channel whose key this account does not hold (CORD-03 §1).
|
||||
"readable" to ConcordActions.canAccessChannel(ConcordCommands.entryFor(sc), state, it.channelIdHex),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -79,12 +86,18 @@ object ConcordChannelCommands {
|
||||
ctx.prepare()
|
||||
// CORD-02 §9: a dissolved community is sealed read-only — held keys still open history, but
|
||||
// nothing new is honored, so refuse to post before we ever build/publish a wrap.
|
||||
if (foldState(ctx, sc).dissolved) {
|
||||
val state = foldState(ctx, sc)
|
||||
if (state.dissolved) {
|
||||
return Output.error("dissolved", "community '$handle' has been dissolved and is read-only (CORD-02 §9)")
|
||||
}
|
||||
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
|
||||
val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch)
|
||||
val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now())
|
||||
// The channel's own plane (CORD-03 §1): root-derived when Public, its held key when
|
||||
// Private — and a refusal, never the root plane, for a Private Channel we hold no key for.
|
||||
val plane =
|
||||
ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId)
|
||||
?: return Output.error("no_channel_key", "channel '$channelRef' is not folded, or is private and this account holds no key for it (CORD-03 §1)")
|
||||
val channel = plane.key
|
||||
val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, plane.epoch, text, TimeUtils.now())
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
// A relay that gates writes behind NIP-42 wants the wrap's author (the stream key) authenticated.
|
||||
ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey))
|
||||
@@ -126,7 +139,20 @@ object ConcordChannelCommands {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
|
||||
val channel = ConcordActions.publicChannel(rootHex.hexToByteArray(), channelId.hexToByteArray(), epoch)
|
||||
val state = foldState(ctx, sc)
|
||||
// A Private Channel is read only on its own key's plane (CORD-03 §1); --root/--epoch pick a
|
||||
// root-derived plane and so apply to Public Channels only.
|
||||
val privatePlane =
|
||||
if (state.channels[channelId]?.definition?.private == true) {
|
||||
ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId)
|
||||
?: return Output.error("no_channel_key", "channel '$channelRef' is private and this account holds no key for it (CORD-03 §1)")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val channel = privatePlane?.key ?: ConcordActions.publicChannel(rootHex.hexToByteArray(), channelId.hexToByteArray(), epoch)
|
||||
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val epoch = privatePlane?.epoch ?: epoch
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
// The channel plane is NIP-42-gated to its own derived stream key; register it so the drain authenticates.
|
||||
ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey))
|
||||
@@ -152,13 +178,23 @@ object ConcordChannelCommands {
|
||||
): ConcordCommunityState {
|
||||
val controlPlane = ConcordCommands.controlPlaneKeysFor(sc)
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
// The relays gate the plane's kind-1059 behind NIP-42 as the stream key — register it so
|
||||
// the drain's AUTH challenge is answered as the control plane, not the account. On a split
|
||||
// epoch only staff hold that secret (CORD-02 §2); a plain member registers nothing and
|
||||
// relies on the relay serving the plane unauthenticated.
|
||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(controlPlane.signer?.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
return ConcordActions.foldCommunity(wraps, controlPlane, sc.owner)
|
||||
// The dissolution tombstone lives at its own id-derived address (CORD-02 §9), drained alongside.
|
||||
val dissolved = ConcordDissolution.planeKey(sc.communityId)
|
||||
val dissolvedAddress = dissolved.publicKeyHex
|
||||
// The relays gate each plane's kind-1059 behind NIP-42 as the stream key — register them so
|
||||
// the drain's AUTH challenge is answered as the plane, not the account. On a split epoch
|
||||
// only staff hold the control secret (CORD-02 §2); a plain member relies on the relay
|
||||
// serving that plane unauthenticated. The dissolved plane's key derives from the public
|
||||
// community id, so every member can always answer for it.
|
||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(controlPlane.signer?.secretKey, dissolved.secretKey))
|
||||
val wraps =
|
||||
ctx
|
||||
.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(listOf(controlPlane.address, dissolvedAddress))) }, pendingOnAuthRequired = true)
|
||||
.map { it.second }
|
||||
val (graveWraps, controlWraps) = wraps.partition { it.pubKey == dissolvedAddress }
|
||||
return ConcordActions
|
||||
.foldCommunity(controlWraps, controlPlane, sc.communityId.hexToByteArray(), sc.owner)
|
||||
.withDissolved(ConcordDissolution.isDissolved(graveWraps, sc.communityId, sc.owner))
|
||||
}
|
||||
|
||||
/** Resolve a channel handle: the `general` shortcut, a full hex id, or a folded name/id-prefix match. */
|
||||
|
||||
@@ -27,12 +27,19 @@ import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredPrivateChannel
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
|
||||
@@ -40,6 +47,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
@@ -60,7 +68,7 @@ object ConcordCommands {
|
||||
| concord create --name NAME [--about T] create an encrypted Concord community
|
||||
| [--relay wss://a,wss://b] (--relays is accepted as an alias)
|
||||
| concord list list joined Concord communities
|
||||
| concord import fetch + decrypt this account's kind:13302
|
||||
| concord import fetch + decrypt this account's kind:33302
|
||||
| community list (carries heldRoots, CORD-06)
|
||||
| concord channels COMMUNITY list a community's channels
|
||||
| concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id)
|
||||
@@ -73,9 +81,11 @@ object ConcordCommands {
|
||||
| concord join URL redeem an invite link and save the community
|
||||
| concord rekey [COMMUNITY] follow a Refounding we were re-keyed for:
|
||||
| open our blob and adopt the new epoch
|
||||
| concord recover [COMMUNITY] re-resolve the joined-through invite link and
|
||||
| follow a Refounding we were left out of
|
||||
| (CORD-06); refuses if that epoch banned us
|
||||
| concord recover [COMMUNITY] [--rejoin] re-resolve the joined-through invite link and
|
||||
| report whether a Refounding left us behind;
|
||||
| --rejoin re-accepts that link (a bundle never
|
||||
| moves the base on its own, CORD-06 §2);
|
||||
| refuses if that epoch banned us
|
||||
| concord roles COMMUNITY list live roles + current banlist (CORD-04)
|
||||
| concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK)
|
||||
| concord grant COMMUNITY USER ROLE-ID grant a role to a member
|
||||
@@ -84,6 +94,8 @@ object ConcordCommands {
|
||||
| concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the
|
||||
| control_root) so removed members lose every
|
||||
| key — the hard removal a ban cannot give
|
||||
| concord dissolve COMMUNITY --yes CORD-02 §9: owner-only, IRREVERSIBLE tombstone
|
||||
| that seals the community read-only for everyone
|
||||
""".trimMargin()
|
||||
|
||||
suspend fun dispatch(
|
||||
@@ -93,7 +105,7 @@ object ConcordCommands {
|
||||
route(
|
||||
"concord",
|
||||
tail,
|
||||
"concord <create|list|import|channels|send|read|invite|revoke|join|recover|rekey|roles|role|grant|ban|unban|refound>",
|
||||
"concord <create|list|import|channels|send|read|invite|revoke|join|recover|rekey|roles|role|grant|ban|unban|refound|dissolve>",
|
||||
help = USAGE,
|
||||
routes =
|
||||
mapOf(
|
||||
@@ -114,6 +126,7 @@ object ConcordCommands {
|
||||
"ban" to { rest -> ConcordModCommands.ban(dataDir, rest) },
|
||||
"unban" to { rest -> ConcordModCommands.unban(dataDir, rest) },
|
||||
"refound" to { rest -> ConcordModCommands.refound(dataDir, rest) },
|
||||
"dissolve" to { rest -> ConcordModCommands.dissolve(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -129,6 +142,9 @@ object ConcordCommands {
|
||||
val relaysAlias = args.flag("relays")
|
||||
val relayArg = parseRelays(args.flag("relay") ?: relaysAlias)
|
||||
args.rejectUnknown()
|
||||
// CORD-02 §6 caps, which every reader also enforces at fold.
|
||||
if (!ConcordLimits.nameFits(name)) return Output.error("bad_args", "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes").let { 2 }
|
||||
if (!ConcordLimits.descriptionFits(about)) return Output.error("bad_args", "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes").let { 2 }
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
@@ -181,7 +197,8 @@ object ConcordCommands {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch this account's own encrypted kind-13302 Concord community list, decrypt it, and
|
||||
* Fetch this account's own encrypted Concord Community List (the kind-33302 fragments, plus the
|
||||
* retired kind-13302 event as a rescue source), decrypt it, and
|
||||
* upsert every community into the local store — crucially carrying each community's
|
||||
* `heldRoots` (the prior-epoch access roots Amethyst accumulates across Refoundings, CORD-06).
|
||||
* With those persisted, `amy concord read --epoch <n>` can re-derive a pre-refounding Chat
|
||||
@@ -194,18 +211,20 @@ object ConcordCommands {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays = (ctx.outboxRelays() + ctx.bootstrapRelays())
|
||||
val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(ctx.signer.pubKey))
|
||||
// The fragmented List (33302, CORD-02 §8) plus the retired single event (13302), which is
|
||||
// still read as a rescue source for memberships only it carries.
|
||||
val filter = Filter(kinds = listOf(ConcordCommunityListFragmentEvent.KIND, ConcordCommunityListEvent.KIND), authors = listOf(ctx.signer.pubKey))
|
||||
val events = ctx.drain(relays.associateWith { listOf(filter) }).map { it.second }
|
||||
val newest =
|
||||
events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
|
||||
?: return Output.error("not_found", "no kind-13302 Concord list published by this account").let { 1 }
|
||||
|
||||
val entries =
|
||||
try {
|
||||
newest.decrypt(ctx.signer)
|
||||
} catch (e: Exception) {
|
||||
return Output.error("decrypt_failed", "could not decrypt kind-13302: ${e.message}").let { 1 }
|
||||
}
|
||||
val set = ConcordListFragmentSet.resolve(events.filterIsInstance<ConcordCommunityListFragmentEvent>(), ctx.signer)
|
||||
val legacy = events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
|
||||
if (set.isEmpty && legacy == null) {
|
||||
return Output.error("not_found", "no Concord Community List (kind 33302 or 13302) published by this account").let { 1 }
|
||||
}
|
||||
val legacyPlaintext = legacy?.decryptPlaintext(ctx.signer)
|
||||
if (set.held.isEmpty() && legacyPlaintext == null) {
|
||||
return Output.error("decrypt_failed", "could not decrypt this account's Concord Community List").let { 1 }
|
||||
}
|
||||
val entries = ConcordCommunityList.decodeDocument(ConcordCommunityList.readWithLegacy(set, legacyPlaintext)).entries
|
||||
val store = ConcordStore(dataDir.concordFile)
|
||||
val existing = store.load().associateBy { it.communityId }
|
||||
val imported =
|
||||
@@ -239,6 +258,7 @@ object ConcordCommands {
|
||||
// Survives every merge: losing the anchor makes the NEXT exclusion
|
||||
// unrecoverable, so a list entry without one must not clear ours.
|
||||
inviteRef = e.inviteRef ?: prior?.inviteRef ?: "",
|
||||
privateChannels = e.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
|
||||
),
|
||||
)
|
||||
mapOf(
|
||||
@@ -269,7 +289,9 @@ object ConcordCommands {
|
||||
ctx.prepare()
|
||||
// The joiner cannot derive the Control Plane address, so the invite carries it
|
||||
// (CORD-05 §1); omitted for a legacy community, which has none to carry.
|
||||
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null })
|
||||
// The creator rides in the bundle so joiners echo it in their Guestbook Join (CORD-05 §1).
|
||||
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null }, creator = ctx.signer.pubKey)
|
||||
// At most 3 bootstrap relays ride in the fragment (CORD-05 §3); the codec truncates.
|
||||
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays)
|
||||
// Record the link BEFORE publishing the bundle (CORD-05, kind 13303): a link whose
|
||||
// `signer_sk` was never stored can never be refreshed, so the next Refounding orphans
|
||||
@@ -417,7 +439,7 @@ object ConcordCommands {
|
||||
// relay that kept the old version hand out a link its creator revoked — and it cannot
|
||||
// tell the user which of "revoked", "expired" or "gone" they are looking at.
|
||||
val bundle =
|
||||
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
|
||||
when (val status = ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token)) {
|
||||
is InviteBundleStatus.Live -> status.invite
|
||||
is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined")
|
||||
InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator")
|
||||
@@ -449,11 +471,11 @@ object ConcordCommands {
|
||||
if (joinEditions.isEmpty()) {
|
||||
return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join")
|
||||
}
|
||||
if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(ctx.signer.pubKey)) {
|
||||
if (AuthorityResolver.resolve(joinEditions, bundle.communityId.hexToByteArray(), bundle.owner).isBanned(ctx.signer.pubKey)) {
|
||||
return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)")
|
||||
}
|
||||
|
||||
ConcordStore(dataDir.concordFile).upsert(
|
||||
val stored =
|
||||
StoredCommunity(
|
||||
name = bundle.name,
|
||||
communityId = bundle.communityId,
|
||||
@@ -468,15 +490,54 @@ object ConcordCommands {
|
||||
// The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving
|
||||
// this link is the only way back (CORD-05/06). Stored bare, domain-agnostic.
|
||||
inviteRef = ConcordActions.bareInviteRef(url) ?: "",
|
||||
),
|
||||
)
|
||||
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays))
|
||||
privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
|
||||
)
|
||||
ConcordStore(dataDir.concordFile).upsert(stored)
|
||||
|
||||
// Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later
|
||||
// Refounding finds this member to re-key, and it echoes the link's attribution so link
|
||||
// holders can count per-link joins. Best-effort, like every Guestbook motion.
|
||||
val announced = announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label)
|
||||
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shared helpers (used by ConcordChannelCommands too) ------------------
|
||||
|
||||
private val HEX64 = Regex("^[0-9a-f]{64}$")
|
||||
|
||||
/** Publishes a Guestbook Join for [sc] at its current epoch, echoing invite attribution; true if a relay took it. */
|
||||
suspend fun announceGuestbookJoin(
|
||||
ctx: Context,
|
||||
sc: StoredCommunity,
|
||||
inviteCreator: String?,
|
||||
inviteLabel: String?,
|
||||
): Boolean {
|
||||
val creator = inviteCreator?.lowercase()?.takeIf { HEX64.matches(it) }
|
||||
val label = inviteLabel?.takeIf { creator != null && it.isNotBlank() }
|
||||
val guestbook = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
|
||||
val wrap = ConcordActions.buildGuestbookJoin(ctx.signer, guestbook, TimeUtils.now(), creator, label)
|
||||
val relays = relaysFor(ctx, sc)
|
||||
ctx.registerConcordStreamKeys(relays, listOf(guestbook.secretKey))
|
||||
return ctx.publish(wrap, relays).values.any { it.accepted }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [sc] carries a valid owner tombstone (CORD-02 §9). Death wins every race: no rekey,
|
||||
* recovery or Refounding moves a dissolved community forward.
|
||||
*/
|
||||
suspend fun isDissolved(
|
||||
ctx: Context,
|
||||
sc: StoredCommunity,
|
||||
): Boolean {
|
||||
val grave = ConcordDissolution.planeKey(sc.communityId)
|
||||
val relays = relaysFor(ctx, sc)
|
||||
ctx.registerConcordStreamKeys(relays, listOf(grave.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(grave.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
return ConcordDissolution.isDissolved(wraps, sc.communityId, sc.owner)
|
||||
}
|
||||
|
||||
fun parseRelays(csv: String?): List<String> = csv?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
|
||||
|
||||
fun normalize(urls: List<String>): Set<NormalizedRelayUrl> = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
@@ -519,7 +580,7 @@ object ConcordCommands {
|
||||
editions: List<ControlEdition>,
|
||||
): Pair<StoredCommunity, ControlPlaneKeys>? {
|
||||
val entry = entryFor(sc)
|
||||
val authority = AuthorityResolver.resolve(editions, sc.owner)
|
||||
val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||
val delivered = ConcordReceive.deliveredControlRoot(entry, editions, authority, ctx.signer) ?: return null
|
||||
val updated = sc.copy(controlRoot = delivered)
|
||||
ConcordStore(dataDir.concordFile).upsert(updated)
|
||||
@@ -537,6 +598,7 @@ object ConcordCommands {
|
||||
controlPk = sc.controlPk.ifBlank { null },
|
||||
controlRoot = sc.controlRoot.ifBlank { null },
|
||||
heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }, it.controlRoot.ifBlank { null }) },
|
||||
privateChannels = sc.privateChannels.map { PrivateChannelKey(it.channelId, it.key, it.epoch, it.name) },
|
||||
relays = sc.relays,
|
||||
name = sc.name,
|
||||
inviteRef = sc.inviteRef.ifBlank { null },
|
||||
@@ -555,26 +617,25 @@ object ConcordCommands {
|
||||
relays = entry.relays,
|
||||
name = entry.name.ifBlank { sc.name },
|
||||
inviteRef = entry.inviteRef ?: sc.inviteRef,
|
||||
privateChannels = entry.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
|
||||
)
|
||||
|
||||
/**
|
||||
* `concord recover [COMMUNITY]` — the stranded-recovery receive path (CORD-05/06 A2).
|
||||
* `concord recover [COMMUNITY] [--rejoin]` — stranded detection (CORD-05/06).
|
||||
*
|
||||
* A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a
|
||||
* member simply left out of the rekey receives nothing and sits on the dead epoch forever while
|
||||
* everyone else moves on. There is no message to miss, which is why the rekey drain cannot help.
|
||||
* The way back is the invite link the membership was joined through: the community keeps
|
||||
* re-minting its bundle at the same addressable coordinate, so a live bundle at a **strictly
|
||||
* higher** epoch than ours proves we were left behind — and carries the new root.
|
||||
* member simply left out of the rekey receives nothing and sits on the dead epoch while
|
||||
* everyone else moves on. The invite link the membership was joined through is re-minted at
|
||||
* the current epoch, so a live bundle there at a **strictly higher** epoch says we were left
|
||||
* behind.
|
||||
*
|
||||
* Amethyst sweeps this on a timer; amy makes it an explicit verb, so it stays deterministic and
|
||||
* scriptable rather than a background loop.
|
||||
* A bundle is NOT proof of continuity (nothing binds `community_root` to `community_id`), so
|
||||
* this verb only reports by default — a link creator must not be able to relocate everyone who
|
||||
* joined through their link (CORD-06 §2: the base moves only by a verifiable rekey).
|
||||
* `--rejoin` is the user explicitly re-accepting that link: the same trust decision as `join`.
|
||||
*
|
||||
* The ban gate is the point of care. A removed member keeps the link's unlock token forever, so
|
||||
* without it this walks them straight back into the epoch they were rotated out of. It reads the
|
||||
* banlist of the epoch we are **leaving** (the last Control Plane we can still fold) and **fails
|
||||
* closed**: a community whose plane will not fold yields no verdict and is skipped, never
|
||||
* recovered.
|
||||
* Ban-gated at the epoch we are leaving and **fails closed** (no fold, no verdict, no rejoin);
|
||||
* a dissolved community is never moved (CORD-02 §9).
|
||||
*/
|
||||
private suspend fun recover(
|
||||
dataDir: DataDir,
|
||||
@@ -582,6 +643,7 @@ object ConcordCommands {
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positionalOrNull(0)
|
||||
val rejoin = args.bool("rejoin")
|
||||
args.rejectUnknown()
|
||||
val store = ConcordStore(dataDir.concordFile)
|
||||
val targets =
|
||||
@@ -595,54 +657,68 @@ object ConcordCommands {
|
||||
ctx.prepare()
|
||||
val results = mutableListOf<Map<String, Any?>>()
|
||||
for (sc in targets) {
|
||||
fun skip(reason: String) = mapOf("community_id" to sc.communityId, "name" to sc.name, "stranded" to false, "recovered" to false, "reason" to reason)
|
||||
|
||||
val inviteRef = sc.inviteRef.ifBlank { null }
|
||||
if (inviteRef == null) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_invite_ref")
|
||||
results += skip("no_invite_ref")
|
||||
continue
|
||||
}
|
||||
val parsed = ConcordActions.parseInviteLink(inviteRef)
|
||||
if (parsed == null) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "bad_invite_ref")
|
||||
results += skip("bad_invite_ref")
|
||||
continue
|
||||
}
|
||||
if (isDissolved(ctx, sc)) {
|
||||
results += skip("dissolved")
|
||||
continue
|
||||
}
|
||||
val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() }
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second }
|
||||
// Only a LIVE bundle recovers: an expired or revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite
|
||||
// Only a LIVE bundle counts: an expired or revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite
|
||||
if (bundle == null) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_live_bundle")
|
||||
results += skip("no_live_bundle")
|
||||
continue
|
||||
}
|
||||
|
||||
// Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict,
|
||||
// no recovery — the gate fails closed rather than assuming "not banned".
|
||||
// Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict.
|
||||
val cp = controlPlaneKeysFor(sc)
|
||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
|
||||
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val editions = ConcordActions.controlEditions(controlWraps, cp)
|
||||
if (editions.isEmpty()) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "control_plane_not_folded")
|
||||
results += skip("control_plane_not_folded")
|
||||
continue
|
||||
}
|
||||
val bannedHere = AuthorityResolver.resolve(editions, sc.owner).isBanned(ctx.signer.pubKey)
|
||||
|
||||
val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere)
|
||||
if (merged == null) {
|
||||
val bannedHere = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner).isBanned(ctx.signer.pubKey)
|
||||
val entry = entryFor(sc)
|
||||
if (!ConcordActions.isStranded(entry, bundle, bannedHere)) {
|
||||
results += skip(if (bannedHere) "banned" else "already_current") + ("root_epoch" to sc.rootEpoch)
|
||||
continue
|
||||
}
|
||||
if (!rejoin) {
|
||||
results +=
|
||||
mapOf(
|
||||
"community_id" to sc.communityId,
|
||||
"name" to sc.name,
|
||||
"stranded" to true,
|
||||
"recovered" to false,
|
||||
"reason" to if (bannedHere) "banned" else "already_current",
|
||||
"reason" to "rejoin_required",
|
||||
"root_epoch" to sc.rootEpoch,
|
||||
"link_epoch" to bundle.rootEpoch,
|
||||
)
|
||||
continue
|
||||
}
|
||||
store.upsert(storedFrom(sc, merged))
|
||||
val merged = ConcordActions.rejoinStranded(entry, bundle, bannedHere) ?: continue
|
||||
val stored = storedFrom(sc, merged)
|
||||
store.upsert(stored)
|
||||
announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label)
|
||||
results +=
|
||||
mapOf(
|
||||
"community_id" to sc.communityId,
|
||||
"name" to sc.name,
|
||||
"stranded" to true,
|
||||
"recovered" to true,
|
||||
"from_epoch" to sc.rootEpoch,
|
||||
"root_epoch" to merged.rootEpoch,
|
||||
@@ -662,8 +738,11 @@ object ConcordCommands {
|
||||
* strands every other CLI member even though their blob is sitting on the relay.
|
||||
*
|
||||
* The rotator is authorized against the roster of the epoch being **left** — `hasPermission`,
|
||||
* never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed:
|
||||
* a plane that will not fold yields no verdict and the community is skipped.
|
||||
* never `effectivePermissions`, so a banned BAN-holder cannot rotate us — and must cite the
|
||||
* Grant it acts under (`vac`, CORD-06 §3) at a version that fold has synced; the owner cites
|
||||
* nothing. Racing honored rotations converge on the lowest new root. Fails closed: a plane that
|
||||
* will not fold yields no verdict and the community is skipped. A dissolved community is never
|
||||
* moved (CORD-02 §9).
|
||||
*/
|
||||
private suspend fun rekey(
|
||||
dataDir: DataDir,
|
||||
@@ -679,21 +758,12 @@ object ConcordCommands {
|
||||
ctx.prepare()
|
||||
val results = mutableListOf<Map<String, Any?>>()
|
||||
for (sc in targets) {
|
||||
if (isDissolved(ctx, sc)) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "dissolved", "root_epoch" to sc.rootEpoch)
|
||||
continue
|
||||
}
|
||||
val relays = relaysFor(ctx, sc)
|
||||
val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
|
||||
ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val received =
|
||||
ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch)
|
||||
if (received == null) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch)
|
||||
continue
|
||||
}
|
||||
if (received.newEpoch <= sc.rootEpoch) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch)
|
||||
continue
|
||||
}
|
||||
// Authorize the rotator against the epoch we are LEAVING — the last plane we can fold.
|
||||
// Authorize against the epoch we are LEAVING — the last plane we can fold.
|
||||
val cp = controlPlaneKeysFor(sc)
|
||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
|
||||
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
@@ -702,12 +772,28 @@ object ConcordCommands {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded")
|
||||
continue
|
||||
}
|
||||
if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.owner), received.rotator)) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator)
|
||||
val entry = entryFor(sc)
|
||||
val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||
val honored = { r: ReceivedRefounding -> ConcordReceive.isHonoredRotation(entry, editions, authority, r) }
|
||||
|
||||
val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
|
||||
ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val received = ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch, accept = honored)
|
||||
if (received == null) {
|
||||
// Distinguish "no blob at all" from "only rotations we refuse to honor".
|
||||
val any = ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch)
|
||||
val reason = if (any == null) "no_blob_for_us" else "unauthorized_rotator"
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to reason, "root_epoch" to sc.rootEpoch) + (if (any != null) mapOf("rotator" to any.rotator) else emptyMap())
|
||||
continue
|
||||
}
|
||||
val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
||||
store.upsert(storedFrom(sc, adopted))
|
||||
if (received.newEpoch <= sc.rootEpoch) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch)
|
||||
continue
|
||||
}
|
||||
val adopted = ConcordReceive.withAdoptedRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
||||
// A rotation we adopted supersedes any Refounding of ours still reserved.
|
||||
store.upsert(storedFrom(sc, adopted).copy(pendingRefounding = null))
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator)
|
||||
}
|
||||
Output.emit(mapOf("communities" to results))
|
||||
|
||||
+122
-33
@@ -26,14 +26,20 @@ import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredPendingRefounding
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -55,7 +61,7 @@ object ConcordModCommands {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val (_, editions) = load(ctx, sc, dataDir)
|
||||
val state = ConcordCommunityState.fold(editions, sc.owner)
|
||||
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"roles" to
|
||||
@@ -91,6 +97,9 @@ object ConcordModCommands {
|
||||
val position = args.positional(2, "position").toLongOrNull() ?: return Output.error("bad_args", "position must be an integer").let { 2 }
|
||||
val permBits = args.positional.drop(3).mapNotNull { permByName(it) }
|
||||
args.rejectUnknown()
|
||||
// The CORD-04 §2/§3 rules every reader enforces at fold, refused here as bad input.
|
||||
if (!ConcordLimits.nameFits(name)) return Output.error("bad_args", "role name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes").let { 2 }
|
||||
if (position < 1) return Output.error("bad_args", "position must be 1 or greater (position 0 is the owner's)").let { 2 }
|
||||
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
@@ -99,7 +108,7 @@ object ConcordModCommands {
|
||||
writeGuard(cp)?.let { return it }
|
||||
val roleId = RandomInstance.bytes(32)
|
||||
val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire())
|
||||
val wrap = ConcordModeration.defineRole(ctx.signer, cp, roleId, role, editions, TimeUtils.now(), owner = sc.owner)
|
||||
val wrap = ConcordModeration.defineRole(ctx.signer, cp, sc.communityId.hexToByteArray(), roleId, role, editions, TimeUtils.now(), owner = sc.owner)
|
||||
val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc))
|
||||
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
|
||||
Output.emit(mapOf("role_id" to roleId.toHexKey(), "name" to name, "position" to position) + RawEventSupport.ackFields(ack))
|
||||
@@ -157,6 +166,38 @@ object ConcordModCommands {
|
||||
rest: Array<String>,
|
||||
): Int = banOrUnban(dataDir, rest, ban = true)
|
||||
|
||||
/**
|
||||
* Dissolves a community (CORD-02 §9): `dissolve <community> --yes`. Publishes the owner-signed,
|
||||
* `eid`-bound tombstone at the community's dissolved address. Owner-only and irreversible, hence
|
||||
* the mandatory `--yes`.
|
||||
*/
|
||||
suspend fun dissolve(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positional(0, "community")
|
||||
val confirmed = args.bool("yes")
|
||||
args.rejectUnknown()
|
||||
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
|
||||
if (!confirmed) return Output.error("confirm", "dissolving '$handle' is irreversible; re-run with --yes")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
if (!sc.owner.equals(ctx.signer.pubKey, ignoreCase = true)) {
|
||||
return Output.error("not_owner", "only the owner can dissolve '$handle' (CORD-02 §9)")
|
||||
}
|
||||
val wrap = ConcordDissolution.build(ctx.signer, sc.communityId)
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
// A relay that gates the plane on NIP-42 wants AUTH as the stream key the wrap is signed by.
|
||||
ctx.registerConcordStreamKeys(relays, listOf(ConcordDissolution.planeKey(sc.communityId).secretKey))
|
||||
val ack = ctx.publish(wrap, relays)
|
||||
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
|
||||
Output.emit(mapOf("community" to sc.communityId, "dissolved" to true) + RawEventSupport.ackFields(ack))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Unbans a member: `unban <community> <user>`. */
|
||||
suspend fun unban(
|
||||
dataDir: DataDir,
|
||||
@@ -249,9 +290,14 @@ object ConcordModCommands {
|
||||
.toSet()
|
||||
if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user")
|
||||
|
||||
// Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored.
|
||||
if (ConcordCommands.isDissolved(ctx, sc)) {
|
||||
return Output.error("dissolved", "community '$handle' has been dissolved; a Refounding cannot cross the tombstone (CORD-02 §9)")
|
||||
}
|
||||
|
||||
val loaded = load(ctx, sc, dataDir)
|
||||
val (cp, editions) = loaded
|
||||
val state = ConcordCommunityState.fold(editions, sc.owner)
|
||||
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||
val authority = state.authority
|
||||
val me = ctx.signer.pubKey
|
||||
|
||||
@@ -269,8 +315,22 @@ object ConcordModCommands {
|
||||
// split epoch it takes the current control_root (CORD-02 §2).
|
||||
writeGuard(cp)?.let { return it }
|
||||
|
||||
// The rotation cites the Grant it acts under (CORD-06 §3 "Authority"), or no receiver
|
||||
// honors it; the owner cites nothing.
|
||||
val citation = ConcordReceive.rotationCitation(ConcordCommands.entryFor(loaded.community), editions, me)
|
||||
if (citation == null && !authority.isOwner(me)) {
|
||||
return Output.error("forbidden", "no Grant of yours in this community's fold to cite; receivers would drop the rotation (CORD-06 §3)")
|
||||
}
|
||||
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
|
||||
// 0. Acquire the WHOLE plane before the first publish (CORD-06 §3: a Refounder that cannot
|
||||
// fold every Control event must abort). Paged to completion, not a single capped REQ.
|
||||
val swept = ctx.drainAllPages(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }).map { it.second }
|
||||
if (swept.isEmpty()) {
|
||||
return Output.error("control_plane_unreadable", "could not page this community's Control Plane; refusing to compact a partial plane (CORD-06 §3)")
|
||||
}
|
||||
|
||||
// 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the
|
||||
// new epoch — carries the ban. Each edition chains onto the updated banlist head.
|
||||
var chain = editions
|
||||
@@ -297,45 +357,67 @@ object ConcordModCommands {
|
||||
|
||||
// 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff
|
||||
// get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one).
|
||||
val newRoot = RandomInstance.bytes(32)
|
||||
val newControlRoot = RandomInstance.bytes(32)
|
||||
// Compact from what we KNOW the plane holds: the wraps we drained plus the bans we just
|
||||
// The keys are RESERVED and persisted before anything is published, so a retried
|
||||
// `refound` re-delivers the same root instead of minting a sibling (CORD-06 §3).
|
||||
val priorRoot = sc.root.hexToByteArray()
|
||||
val keys =
|
||||
ConcordRefounding.reserveKeys(
|
||||
loaded.community.pendingRefounding?.let { PendingRefounding(sc.communityId, it.rootEpoch, it.prevCommit, it.newRoot.hexToByteArray(), it.newControlRoot.hexToByteArray()) },
|
||||
sc.communityId,
|
||||
sc.rootEpoch,
|
||||
priorRoot,
|
||||
)
|
||||
val reserved = loaded.community.copy(pendingRefounding = StoredPendingRefounding(keys.rootEpoch, keys.prevCommit, keys.newRoot.toHexKey(), keys.newControlRoot.toHexKey()))
|
||||
ConcordStore(dataDir.concordFile).upsert(reserved)
|
||||
// Compact from what we KNOW the plane holds: the paged sweep plus the bans we just
|
||||
// published. Re-draining alone would race the relay's indexing, and a relay that has not
|
||||
// yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch
|
||||
// whose roster never banned the member we are removing.
|
||||
val drained = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val controlWraps = (drained + banWraps).distinctBy { it.id }
|
||||
val controlWraps = (swept + banWraps).distinctBy { it.id }
|
||||
val build =
|
||||
ConcordActions.buildRefounding(
|
||||
rotatorSigner = ctx.signer,
|
||||
communityId = sc.communityId,
|
||||
priorRoot = sc.root.hexToByteArray(),
|
||||
newRoot = newRoot,
|
||||
newControlRoot = newControlRoot,
|
||||
rootEpoch = sc.rootEpoch,
|
||||
priorControlWraps = controlWraps,
|
||||
priorControlKeys = cp,
|
||||
recipientsXOnly = recipients,
|
||||
staffXOnly = authority.staffMembers(),
|
||||
createdAt = TimeUtils.now(),
|
||||
ownerPubKey = sc.owner,
|
||||
)
|
||||
try {
|
||||
ConcordActions.buildRefounding(
|
||||
rotatorSigner = ctx.signer,
|
||||
communityId = sc.communityId,
|
||||
priorRoot = priorRoot,
|
||||
newRoot = keys.newRoot,
|
||||
newControlRoot = keys.newControlRoot,
|
||||
rootEpoch = sc.rootEpoch,
|
||||
priorControlWraps = controlWraps,
|
||||
priorControlKeys = cp,
|
||||
recipientsXOnly = recipients,
|
||||
staffXOnly = authority.staffMembers(),
|
||||
createdAt = TimeUtils.now(),
|
||||
ownerPubKey = sc.owner,
|
||||
authority = citation,
|
||||
// Every head our own fold honors (bans included) must survive the compaction.
|
||||
mustCarry = ConcordRefounding.headVersions(chain, sc.communityId.hexToByteArray(), sc.owner),
|
||||
)
|
||||
} catch (e: IncompleteControlPlaneException) {
|
||||
return Output.error("control_plane_incomplete", "${e.missing.size} Control Plane head(s) could not be carried into the new epoch; aborted before publishing the rotation (CORD-06 §3)")
|
||||
}
|
||||
|
||||
// 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it).
|
||||
build.controlWraps.forEach { ctx.publish(it, relays) }
|
||||
build.rekeyWraps.forEach { ctx.publish(it, relays) }
|
||||
// 4. The root roll FIRST, every chunk confirmed; the compacted plane only after it
|
||||
// (CORD-06 §3). A chunk no relay took aborts with nothing adopted — the reserved keys
|
||||
// make re-running this command re-deliver the same root.
|
||||
for (wrap in build.rekeyWraps) {
|
||||
if (ctx.publish(wrap, relays).values.none { it.accepted }) {
|
||||
return Output.error("rekey_not_published", "a rekey chunk was not accepted by any relay; re-run to resume with the same keys")
|
||||
}
|
||||
}
|
||||
val compactionFailures = build.controlWraps.count { wrap -> ctx.publish(wrap, relays).values.none { it.accepted } }
|
||||
|
||||
// 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the
|
||||
// epoch we are leaving for the anti-rollback floor.
|
||||
// epoch we are leaving for the anti-rollback floor — and drop the reservation.
|
||||
val adopted =
|
||||
ConcordReceive.withAdoptedRoot(
|
||||
ConcordCommands.entryFor(loaded.community),
|
||||
newRoot,
|
||||
keys.newRoot,
|
||||
build.newEpoch,
|
||||
build.newControlKeys.address.hexToByteArray(),
|
||||
newControlRoot,
|
||||
keys.newControlRoot,
|
||||
)
|
||||
val stored = ConcordCommands.storedFrom(loaded.community, adopted)
|
||||
val stored = ConcordCommands.storedFrom(loaded.community, adopted).copy(pendingRefounding = null)
|
||||
ConcordStore(dataDir.concordFile).upsert(stored)
|
||||
|
||||
// 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new
|
||||
@@ -363,7 +445,7 @@ object ConcordModCommands {
|
||||
// grants, icon, label — survive the rotation, and so a coordinate whose newest
|
||||
// event is a revocation tombstone is left revoked instead of being re-opened.
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second }
|
||||
val live = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching
|
||||
val live = ConcordActions.classifyInvite(wraps, link.signerPubKeyHex(), token) as? InviteBundleStatus.Live ?: return@runCatching
|
||||
val moved =
|
||||
live.invite.copy(
|
||||
communityRoot = stored.root,
|
||||
@@ -385,6 +467,7 @@ object ConcordModCommands {
|
||||
"recipients" to recipients.size,
|
||||
"control_wraps" to build.controlWraps.size,
|
||||
"rekey_wraps" to build.rekeyWraps.size,
|
||||
"compaction_failures" to compactionFailures,
|
||||
"invites_refreshed" to refreshed,
|
||||
),
|
||||
)
|
||||
@@ -453,12 +536,15 @@ object ConcordModCommands {
|
||||
): Set<String> {
|
||||
val out = HashSet<String>()
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
for ((channelIdHex, _) in state.channels) {
|
||||
val entry = ConcordCommands.entryFor(sc)
|
||||
for (channelIdHex in state.channels.keys) {
|
||||
// A Private Channel we hold no key for has no plane we may read (CORD-03 §1).
|
||||
val plane = ConcordActions.currentChannelPlane(entry, state, channelIdHex) ?: continue
|
||||
runCatching {
|
||||
val key = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelIdHex.hexToByteArray(), sc.rootEpoch)
|
||||
val key = plane.key
|
||||
ctx.registerConcordStreamKeys(relays, listOf(key.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(key.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
ConcordActions.channelMessages(wraps, key, channelIdHex, sc.rootEpoch).mapTo(out) { it.author.lowercase() }
|
||||
ConcordActions.channelMessages(wraps, key, channelIdHex, plane.epoch).mapTo(out) { it.author.lowercase() }
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -513,6 +599,9 @@ object ConcordModCommands {
|
||||
"BAN" -> ConcordPermissions.BAN
|
||||
"MANAGE_MESSAGES" -> ConcordPermissions.MANAGE_MESSAGES
|
||||
"CREATE_INVITE" -> ConcordPermissions.CREATE_INVITE
|
||||
"VIEW_AUDIT_LOG" -> ConcordPermissions.VIEW_AUDIT_LOG
|
||||
"MENTION_EVERYONE" -> ConcordPermissions.MENTION_EVERYONE
|
||||
"PIN_MESSAGES" -> ConcordPermissions.PIN_MESSAGES
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,29 @@ data class StoredCommunity(
|
||||
// rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct
|
||||
// invite or a community joined before amy stored it.
|
||||
val inviteRef: String = "",
|
||||
// Private Channel keys this account holds (CORD-03 §1), from the Community List or an invite.
|
||||
// A private channel is read and written ONLY on the plane its own key derives; without one it
|
||||
// is unreadable and `send` refuses rather than fall back to the root-derived plane.
|
||||
val privateChannels: List<StoredPrivateChannel> = emptyList(),
|
||||
// Keys reserved for a Refounding this account started but has not yet adopted (CORD-06 §3): a
|
||||
// retried `refound` must re-deliver the SAME root, never mint a sibling that splits the members.
|
||||
val pendingRefounding: StoredPendingRefounding? = null,
|
||||
)
|
||||
|
||||
/** A held Private Channel key at its channel epoch, mirroring quartz `PrivateChannelKey`. */
|
||||
data class StoredPrivateChannel(
|
||||
val channelId: String = "",
|
||||
val key: String = "",
|
||||
val epoch: Long = 0,
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
/** A Refounding's reserved keys, mirroring quartz `PendingRefounding`. */
|
||||
data class StoredPendingRefounding(
|
||||
val rootEpoch: Long = 0,
|
||||
val prevCommit: String = "",
|
||||
val newRoot: String = "",
|
||||
val newControlRoot: String = "",
|
||||
)
|
||||
|
||||
/** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */
|
||||
|
||||
+189
-25
@@ -29,8 +29,10 @@ import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite
|
||||
@@ -68,15 +70,19 @@ data class ConcordChatMessage(
|
||||
)
|
||||
|
||||
/**
|
||||
* One channel's Chat Plane at a prior epoch: the epoch-invariant [channelIdHex], the [epoch] the
|
||||
* wraps are bound to (for `isBoundTo` validation), and the derived [key] to decrypt them.
|
||||
* One channel's Chat Plane: the epoch-invariant [channelIdHex], the [epoch] its rumors are bound to
|
||||
* (for `isBoundTo` validation — the root epoch for a Public Channel, the channel's own epoch for a
|
||||
* Private one, CORD-03 §1), and the derived [key] its wraps are addressed by and decrypt under.
|
||||
*/
|
||||
data class HistoricalChannelPlane(
|
||||
data class ChannelPlane(
|
||||
val channelIdHex: HexKey,
|
||||
val epoch: Long,
|
||||
val key: GroupKey,
|
||||
)
|
||||
|
||||
/** A [ChannelPlane] at a prior epoch (pre-Refounding history). */
|
||||
typealias HistoricalChannelPlane = ChannelPlane
|
||||
|
||||
/**
|
||||
* Concord community verbs — pure builders, plane-key derivation, relay-filter
|
||||
* assembly, and event folding usable from amy CLI, the Android app, and any other
|
||||
@@ -142,6 +148,92 @@ object ConcordActions {
|
||||
rootEpoch: Long,
|
||||
): GroupKey = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch)
|
||||
|
||||
private val HEX64 = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
/**
|
||||
* The independent key this account holds for Private Channel [channelIdHex] (delivered on grant
|
||||
* and carried in the Community List's `privateChannels`, CORD-03 §1 / CORD-02 §8), or null when
|
||||
* it holds none. A keyless entry (a writer listing a public channel as `{id, epoch}`) is not a key.
|
||||
*/
|
||||
fun heldPrivateChannelKey(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
): PrivateChannelKey? = entry.privateChannels.firstOrNull { it.channelId.equals(channelIdHex, ignoreCase = true) && HEX64.matches(it.key) }
|
||||
|
||||
/**
|
||||
* The Chat Plane a channel is **written** on, or null when this account cannot write it
|
||||
* (CORD-03 §1):
|
||||
* - Public: `group_key("concord/channel", community_root, channel_id, root_epoch)`, bound to
|
||||
* the root epoch;
|
||||
* - Private: `group_key("concord/channel", channel_key, channel_id, channel_epoch)` from the
|
||||
* held key, bound to the **channel** epoch — and null when no key is held. A Private Channel
|
||||
* must never fall back to the root-derived plane: every member decrypts that one, so a post
|
||||
* there would be public to the whole community under a Lock icon.
|
||||
*/
|
||||
fun currentChannelPlane(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
isPrivate: Boolean,
|
||||
): ChannelPlane? {
|
||||
val channelId = channelIdHex.hexToByteArray()
|
||||
if (isPrivate) {
|
||||
val held = heldPrivateChannelKey(entry, channelIdHex) ?: return null
|
||||
return ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelId, held.epoch))
|
||||
}
|
||||
return ChannelPlane(channelIdHex, entry.rootEpoch, publicChannel(entry.root.hexToByteArray(), channelId, entry.rootEpoch))
|
||||
}
|
||||
|
||||
/**
|
||||
* [currentChannelPlane] for a channel of the folded [state], or null when the channel is not in
|
||||
* the fold (unknown or deleted) or is Private with no held key.
|
||||
*/
|
||||
fun currentChannelPlane(
|
||||
entry: ConcordCommunityListEntry,
|
||||
state: ConcordCommunityState,
|
||||
channelIdHex: HexKey,
|
||||
): ChannelPlane? {
|
||||
val def = state.channels[channelIdHex]?.definition ?: return null
|
||||
return currentChannelPlane(entry, channelIdHex, def.private)
|
||||
}
|
||||
|
||||
/**
|
||||
* The older Chat Planes of a channel this account can still read, beside [currentChannelPlane]:
|
||||
* - Public: its plane under every held prior root ([historicalChannelPlanes]), plus the
|
||||
* private-era plane when a channel key is held (a channel that was Private before);
|
||||
* - Private: none. Only the channel-key planes are its own; the root-derived plane is readable
|
||||
* by every member, so showing it would present public content as private (Armada
|
||||
* `channelsView`). With no priors kept per channel key, that leaves nothing.
|
||||
*/
|
||||
fun historicalChannelPlanes(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
isPrivate: Boolean,
|
||||
): List<ChannelPlane> {
|
||||
if (isPrivate) return emptyList()
|
||||
val rootEras = historicalChannelPlanes(entry.heldRoots, listOf(channelIdHex))
|
||||
val privateEra =
|
||||
heldPrivateChannelKey(entry, channelIdHex)?.let { held ->
|
||||
ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelIdHex.hexToByteArray(), held.epoch))
|
||||
}
|
||||
return rootEras + listOfNotNull(privateEra)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Private Channel keys an [invite] delivers (CORD-05 §1), as Community List entries. A
|
||||
* keyless listing (a public channel written as `{id, epoch}`) delivers nothing.
|
||||
*/
|
||||
fun privateChannelKeysOf(invite: CommunityInvite): List<PrivateChannelKey> =
|
||||
invite.channels
|
||||
.filter { HEX64.matches(it.id) && HEX64.matches(it.key) }
|
||||
.map { PrivateChannelKey(it.id.lowercase(), it.key.lowercase(), it.epoch, it.name) }
|
||||
|
||||
/** True when this account can read and write [channelIdHex] as folded in [state]. */
|
||||
fun canAccessChannel(
|
||||
entry: ConcordCommunityListEntry,
|
||||
state: ConcordCommunityState,
|
||||
channelIdHex: HexKey,
|
||||
): Boolean = currentChannelPlane(entry, state, channelIdHex) != null
|
||||
|
||||
/**
|
||||
* How many prior epochs of channel history to backfill. A CORD-06 Refounding rotates the
|
||||
* `community_root` and bumps the epoch, so pre-refounding messages live under a *different*
|
||||
@@ -190,6 +282,19 @@ object ConcordActions {
|
||||
rootEpoch: Long,
|
||||
): GroupKey = ConcordKeyDerivation.baseRekeyAddress(communityRoot, communityId, rootEpoch + 1)
|
||||
|
||||
/**
|
||||
* The base-rekey address the rotation INTO [entry]'s current epoch rode on, derived from the
|
||||
* prior epoch's (canonical) held root — or null when we hold none (a fresh joiner at this
|
||||
* epoch). Watching it after adopting is what lets the same-epoch race heal (CORD-06 §3): a
|
||||
* racing sibling rotation sealed under the same prior root arrives here, and a strictly lower
|
||||
* one replaces the root we adopted.
|
||||
*/
|
||||
fun siblingBaseRekeyPlane(entry: ConcordCommunityListEntry): GroupKey? {
|
||||
if (entry.rootEpoch <= 0) return null
|
||||
val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).firstOrNull { it.epoch == entry.rootEpoch - 1 } ?: return null
|
||||
return ConcordKeyDerivation.baseRekeyAddress(prior.key.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch)
|
||||
}
|
||||
|
||||
// ---- relay filters (what to REQ) -----------------------------------------
|
||||
|
||||
/** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */
|
||||
@@ -226,21 +331,26 @@ object ConcordActions {
|
||||
icon: ImagePointer? = null,
|
||||
): NewConcordCommunity = ConcordCommunityFactory.create(ownerSigner, name, createdAt, description, relays, icon)
|
||||
|
||||
/** Opens the control-plane [wraps] into their [ControlEdition]s (drops any that don't open/parse). */
|
||||
/**
|
||||
* Opens the control-plane [wraps] into their [ControlEdition]s, dropping any that don't open or
|
||||
* parse — including an edition under an encrypted seal, which the Control Plane never carries
|
||||
* (CORD-02 §5: its seals MUST be plaintext kind 20014).
|
||||
*/
|
||||
fun controlEditions(
|
||||
wraps: List<Event>,
|
||||
controlPlane: ControlPlaneKeys,
|
||||
): List<ControlEdition> =
|
||||
wraps.mapNotNull { wrap ->
|
||||
ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromRumor(it.rumor) }
|
||||
ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromOpened(it) }
|
||||
}
|
||||
|
||||
/** Opens the control-plane [wraps] and folds them into the live community state. */
|
||||
fun foldCommunity(
|
||||
wraps: List<Event>,
|
||||
controlPlane: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
ownerPubKey: HexKey,
|
||||
): ConcordCommunityState = ConcordCommunityState.fold(controlEditions(wraps, controlPlane), ownerPubKey)
|
||||
): ConcordCommunityState = ConcordCommunityState.fold(controlEditions(wraps, controlPlane), communityId, ownerPubKey)
|
||||
|
||||
// ---- channel chat ---------------------------------------------------------
|
||||
|
||||
@@ -344,6 +454,24 @@ object ConcordActions {
|
||||
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an encrypted-seal **delete** wrap (kind-5 [ChannelChat.delete] of the author's own
|
||||
* [targets]) on the [channel] plane — the in-stream delete of CORD-01. Never publish a Concord
|
||||
* delete any other way: a signed kind 5 or a NIP-17 delete would carry the rumor ids outside
|
||||
* the community.
|
||||
*/
|
||||
suspend fun buildChannelDelete(
|
||||
authorSigner: NostrSigner,
|
||||
channel: GroupKey,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
targets: List<Event>,
|
||||
createdAt: Long,
|
||||
): Event {
|
||||
val rumor = ChannelChat.delete(authorSigner.pubKey, channelId, epoch, targets, createdAt)
|
||||
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
|
||||
}
|
||||
|
||||
/** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */
|
||||
suspend fun buildChannelReaction(
|
||||
authorSigner: NostrSigner,
|
||||
@@ -376,8 +504,9 @@ object ConcordActions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the channel [wraps], keeps the kind-9 messages correctly bound to
|
||||
* [channelId]/[epoch], and returns them oldest-first (createdAt, then id).
|
||||
* Opens the channel [wraps], keeps the kind-9 messages that pass the Chat ingest gate
|
||||
* ([channelRumors]), and returns them oldest-first by their CORD-02 §4 send time
|
||||
* (`created_at * 1000 + ms`), then id.
|
||||
*/
|
||||
fun channelMessages(
|
||||
wraps: List<Event>,
|
||||
@@ -385,11 +514,11 @@ object ConcordActions {
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
): List<ConcordChatMessage> =
|
||||
wraps
|
||||
.mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor }
|
||||
.filter { it.kind == ChatEvent.KIND && ChannelChat.isBoundTo(it, channelId, epoch) }
|
||||
channelRumors(wraps, channel, channelId, epoch)
|
||||
.filter { it.kind == ChatEvent.KIND }
|
||||
.distinctBy { it.id }
|
||||
.sortedWith(compareBy({ ChannelChat.orderingMs(it) }, { it.id }))
|
||||
.map { ConcordChatMessage(it.id, it.pubKey, it.content, it.createdAt, channelId, epoch) }
|
||||
.sortedWith(compareBy({ it.createdAt }, { it.id }))
|
||||
|
||||
/**
|
||||
* Opens the channel [wraps] and returns every validated inner rumor bound to
|
||||
@@ -404,10 +533,20 @@ object ConcordActions {
|
||||
channel: GroupKey,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
): List<Event> =
|
||||
wraps
|
||||
.mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor }
|
||||
.filter { ChannelChat.isBoundTo(it, channelId, epoch) }
|
||||
): List<Event> = wraps.mapNotNull { wrap -> openChannelRumor(wrap, channel, channelId, epoch) }
|
||||
|
||||
/**
|
||||
* Opens one channel [wrap] and returns its rumor only when it passes the Chat ingest gate
|
||||
* ([ChannelChat.acceptOpened]): an encrypted 20013 seal, a Chat kind (never another plane's
|
||||
* kind), a strict `channel`/`epoch` binding, and a well-formed `ms`. Anything else is dropped
|
||||
* here, before it can reach the store.
|
||||
*/
|
||||
fun openChannelRumor(
|
||||
wrap: Event,
|
||||
channel: GroupKey,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch) }
|
||||
|
||||
// ---- invites --------------------------------------------------------------
|
||||
|
||||
@@ -426,6 +565,8 @@ object ConcordActions {
|
||||
name: String,
|
||||
relays: List<String>,
|
||||
controlPk: HexKey? = null,
|
||||
creator: HexKey? = null,
|
||||
label: String? = null,
|
||||
): CommunityInvite =
|
||||
CommunityInvite(
|
||||
communityId = communityIdHex,
|
||||
@@ -436,6 +577,10 @@ object ConcordActions {
|
||||
controlPk = controlPk,
|
||||
relays = relays,
|
||||
name = name,
|
||||
// Optional attribution (CORD-05 §1): echoed in the joiner's Guestbook Join, so link
|
||||
// holders can count per-link usage. Inside the token-encrypted bundle only.
|
||||
creatorNpub = creator,
|
||||
label = label,
|
||||
)
|
||||
|
||||
/** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */
|
||||
@@ -495,15 +640,25 @@ object ConcordActions {
|
||||
fun bareInviteRef(url: String): String? = ConcordInviteLink.bareForm(url)
|
||||
|
||||
/**
|
||||
* Merges a stranded membership forward onto a higher-epoch [bundle] resolved at
|
||||
* its own stored invite link, or null when there is nothing to recover. See
|
||||
* [ConcordStrandedRecovery].
|
||||
* True when a live [bundle] resolved at [entry]'s own stored invite link says a Refounding
|
||||
* left us behind (a higher epoch, and we are not banned). Detection only: a bundle may never
|
||||
* move a held community's base on its own (CORD-06 §2) — see [ConcordStrandedRecovery].
|
||||
*/
|
||||
fun recoverStranded(
|
||||
fun isStranded(
|
||||
entry: ConcordCommunityListEntry,
|
||||
bundle: CommunityInvite,
|
||||
bannedAtCurrentEpoch: Boolean,
|
||||
): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle, bannedAtCurrentEpoch)
|
||||
): Boolean = ConcordStrandedRecovery.isStranded(entry, bundle, bannedAtCurrentEpoch)
|
||||
|
||||
/**
|
||||
* The entry after the user **explicitly** re-accepts the invite link a stranded [entry] was
|
||||
* joined through, or null when not stranded. Only ever from a user action — never a sweep.
|
||||
*/
|
||||
fun rejoinStranded(
|
||||
entry: ConcordCommunityListEntry,
|
||||
bundle: CommunityInvite,
|
||||
bannedAtCurrentEpoch: Boolean,
|
||||
): ConcordCommunityListEntry? = ConcordStrandedRecovery.rejoinForward(entry, bundle, bannedAtCurrentEpoch)
|
||||
|
||||
/** Decrypts + validates a fetched bundle event with the link token; null if invalid. */
|
||||
fun openBundle(
|
||||
@@ -516,13 +671,15 @@ object ConcordActions {
|
||||
* [InviteBundleStatus] (live / expired / revoked / unreadable / absent) per CORD-05
|
||||
* §2, so a redeeming client honours a `vsk=9` revocation tombstone and an
|
||||
* `expires_at` in the past, and reports why a link can't be opened instead of
|
||||
* retrying blindly. [nowMs] is unix milliseconds.
|
||||
* retrying blindly. [nowMs] is unix milliseconds. Only events genuinely at the link's
|
||||
* coordinate count — signed by [linkSignerPubKey], `d == ""` — never what a relay claims is.
|
||||
*/
|
||||
fun classifyInvite(
|
||||
wraps: List<Event>,
|
||||
linkSignerPubKey: HexKey,
|
||||
token: ByteArray,
|
||||
nowMs: Long = TimeUtils.nowMillis(),
|
||||
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token, nowMs)
|
||||
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, linkSignerPubKey, token, nowMs)
|
||||
|
||||
/**
|
||||
* The Control Plane keys described by a redeemed [invite] so the joiner can
|
||||
@@ -610,6 +767,8 @@ object ConcordActions {
|
||||
staffXOnly: Set<HexKey>,
|
||||
createdAt: Long,
|
||||
ownerPubKey: HexKey,
|
||||
authority: AuthorityCitation? = null,
|
||||
mustCarry: Map<String, Long> = emptyMap(),
|
||||
): RefoundingBuild =
|
||||
ConcordRefounding.build(
|
||||
rotatorSigner = rotatorSigner,
|
||||
@@ -624,6 +783,8 @@ object ConcordActions {
|
||||
staffXOnly = staffXOnly,
|
||||
createdAt = createdAt,
|
||||
ownerPubKey = ownerPubKey,
|
||||
authority = authority,
|
||||
mustCarry = mustCarry,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -632,7 +793,9 @@ object ConcordActions {
|
||||
* scope, epoch and continuity against the [priorRoot] the member holds — and,
|
||||
* on a staff blob, that the delivered `control_root` derives to the delivered
|
||||
* `control_pk` (CORD-06 §1). Returns the new root + Control keys + rotator
|
||||
* (for the caller to authorize) or null if not re-keyed.
|
||||
* or null if not re-keyed. [accept] is the caller's authority check (see
|
||||
* [ConcordReceive.isHonoredRotation]); racing rotations it admits converge on
|
||||
* the lowest new root (CORD-06 §3).
|
||||
*/
|
||||
suspend fun openBaseRekey(
|
||||
wraps: List<Event>,
|
||||
@@ -641,5 +804,6 @@ object ConcordActions {
|
||||
communityId: HexKey,
|
||||
priorRoot: ByteArray,
|
||||
rootEpoch: Long,
|
||||
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch)
|
||||
accept: (ReceivedRefounding) -> Boolean = { true },
|
||||
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch, accept)
|
||||
}
|
||||
|
||||
+104
-45
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.commons.actions
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitations
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
|
||||
@@ -41,6 +43,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
|
||||
@@ -52,12 +55,19 @@ import kotlinx.serialization.builtins.serializer
|
||||
* re-encryption across epochs) and wrapped on the community's Control Plane. The
|
||||
* caller passes the community's **current** editions so this can chain the next
|
||||
* version onto the entity's head (`version = head.version + 1`, `prevHash =
|
||||
* head.hash`) and union the banlist. Authority is enforced at *fold* time by the
|
||||
* `AuthorityResolver`, not here — an edition whose author doesn't outrank its
|
||||
* target (or trace to the owner via [citation]) is simply dropped by every client.
|
||||
* head.hash`, a new entity starting at version 1) and read the banlist. Authority is
|
||||
* enforced at *fold* time by the `AuthorityResolver`, not here — an edition whose
|
||||
* author doesn't outrank its target is simply dropped by every client.
|
||||
*
|
||||
* The owner needs no [citation]; a delegated moderator must cite the grant they
|
||||
* act under so the fold can verify the chain terminates at the owner.
|
||||
* Every non-owner edition carries the `vac` authority citation (CORD-04 §1/§5): the
|
||||
* actor's own Grant head as the same [current] editions fold it
|
||||
* ([AuthorityCitations.forActor]), which every reader, the reference client included,
|
||||
* requires before honoring the action. The owner cites nothing. A caller may still pass
|
||||
* an explicit [AuthorityCitation] to override it.
|
||||
*
|
||||
* The CORD-04 §2 / CORD-02 §6 caps ([ConcordLimits]) are enforced here too: an edition
|
||||
* every reader would drop is refused with an [IllegalArgumentException] rather than
|
||||
* published.
|
||||
*/
|
||||
object ConcordModeration {
|
||||
/**
|
||||
@@ -83,42 +93,66 @@ object ConcordModeration {
|
||||
*/
|
||||
private fun headOf(
|
||||
current: List<ControlEdition>,
|
||||
communityId: ByteArray,
|
||||
entityId: ByteArray,
|
||||
owner: HexKey,
|
||||
): ControlEdition? = ConcordCommunityState.authorizedHeads(current, owner)[entityId.toHexKey()]?.known
|
||||
): ControlEdition? = ConcordCommunityState.authorizedHeads(current, communityId, owner)[entityId.toHexKey()]?.known
|
||||
|
||||
/** version/prevHash to chain onto the current head of [entityId], or genesis. */
|
||||
private fun versioning(
|
||||
current: List<ControlEdition>,
|
||||
entityId: ByteArray,
|
||||
owner: HexKey,
|
||||
): Pair<Long, ByteArray?> {
|
||||
val head = headOf(current, entityId, owner)
|
||||
return if (head != null) (head.version + 1) to head.hash else 0L to null
|
||||
}
|
||||
/** version/prevHash to chain onto the current head of [entityId], or a genesis at version 1 (CORD-04 §1). */
|
||||
private fun versioning(head: ControlEdition?): Pair<Long, ByteArray?> = if (head != null) (head.version + 1) to head.hash else 1L to null
|
||||
|
||||
private suspend fun wrap(
|
||||
actor: NostrSigner,
|
||||
controlPlane: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
kind: ControlEntityKind,
|
||||
entityId: ByteArray,
|
||||
version: Long,
|
||||
prevHash: ByteArray?,
|
||||
head: ControlEdition?,
|
||||
content: String,
|
||||
current: List<ControlEdition>,
|
||||
createdAt: Long,
|
||||
citation: AuthorityCitation?,
|
||||
owner: HexKey,
|
||||
): Event {
|
||||
val rumor = ControlEditionBuilder.rumor(actor.pubKey, kind, entityId, version, prevHash, content, createdAt, citation)
|
||||
val (version, prevHash) = versioning(head)
|
||||
val vac = citation ?: AuthorityCitations.forActor(current, communityId, owner, actor.pubKey)
|
||||
val rumor = ControlEditionBuilder.rumor(actor.pubKey, kind, entityId, version, prevHash, content, createdAt, vac)
|
||||
return ConcordStreamEnvelope.wrap(rumor, controlPlane, actor, encrypted = false, createdAt = createdAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes [value] as the next edition of [entityId]: laid over the current authorized head's
|
||||
* content, so every field the head carries that [serializer] does not model survives the edit
|
||||
* (CORD-02 §6 — renaming never wipes another client's `custom` or a newer protocol field),
|
||||
* chained onto that head and cited.
|
||||
*/
|
||||
private suspend fun <T> edit(
|
||||
actor: NostrSigner,
|
||||
controlPlane: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
kind: ControlEntityKind,
|
||||
entityId: ByteArray,
|
||||
serializer: KSerializer<T>,
|
||||
value: T,
|
||||
current: List<ControlEdition>,
|
||||
createdAt: Long,
|
||||
citation: AuthorityCitation?,
|
||||
owner: HexKey,
|
||||
): Event {
|
||||
val head = headOf(current, communityId, entityId, owner)
|
||||
val content = ConcordJson.encodePreserving(serializer, value, head?.content)
|
||||
return wrap(actor, controlPlane, communityId, kind, entityId, head, content, current, createdAt, citation, owner)
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines (or updates) a role. [roleId] is the role's stable 32-byte entity id
|
||||
* — generate one for a new role and reuse it to edit or [RoleEntity.deleted] it.
|
||||
* — generate one for a new role and reuse it to edit or [RoleEntity.deleted] it. The
|
||||
* content always carries it as `role_id` (CORD-04 §2), which the reference client requires.
|
||||
*/
|
||||
suspend fun defineRole(
|
||||
actor: NostrSigner,
|
||||
controlPlane: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
roleId: ByteArray,
|
||||
role: RoleEntity,
|
||||
current: List<ControlEdition>,
|
||||
@@ -126,21 +160,23 @@ object ConcordModeration {
|
||||
citation: AuthorityCitation? = null,
|
||||
owner: HexKey,
|
||||
): Event {
|
||||
val (version, prev) = versioning(current, roleId, owner)
|
||||
val content = ConcordJson.instance.encodeToString(RoleEntity.serializer(), role)
|
||||
return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, version, prev, content, createdAt, citation)
|
||||
require(ConcordLimits.nameFits(role.name)) { "role name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" }
|
||||
// CORD-04 §3: position 0 is the owner's alone — refuse here rather than have every reader drop it.
|
||||
require(role.deleted || role.position >= 1) { "role position must be 1 or greater (position 0 is the owner's)" }
|
||||
val stamped = role.copy(roleId = roleId.toHexKey())
|
||||
return edit(actor, controlPlane, communityId, ControlEntityKind.ROLE, roleId, RoleEntity.serializer(), stamped, current, createdAt, citation, owner)
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines (or updates) a channel (CORD-03/04, `vsk=2`). [channelId] is the channel's stable
|
||||
* 32-byte entity id — generate one for a new channel and reuse it to rename, flip its
|
||||
* private/voice flags, or [ChannelEntity.deleted] it (terminal; the id is never reused).
|
||||
* Honored at fold only when [actor] holds MANAGE_CHANNELS (or is the owner) tracing to the owner
|
||||
* via [citation].
|
||||
* Honored at fold only when [actor] holds MANAGE_CHANNELS (or is the owner).
|
||||
*/
|
||||
suspend fun defineChannel(
|
||||
actor: NostrSigner,
|
||||
controlPlane: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
channelId: ByteArray,
|
||||
channel: ChannelEntity,
|
||||
current: List<ControlEdition>,
|
||||
@@ -148,16 +184,34 @@ object ConcordModeration {
|
||||
citation: AuthorityCitation? = null,
|
||||
owner: HexKey,
|
||||
): Event {
|
||||
val (version, prev) = versioning(current, channelId, owner)
|
||||
val content = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), channel)
|
||||
return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation)
|
||||
// Every reader drops an edition naming an empty or over-cap Channel (CORD-03 §2), so
|
||||
// refuse to mint one rather than publish an edition nobody will honor.
|
||||
require(channel.hasValidName()) { "Channel name must be 1..${ChannelEntity.NAME_MAX_BYTES} UTF-8 bytes" }
|
||||
return edit(actor, controlPlane, communityId, ControlEntityKind.CHANNEL, channelId, ChannelEntity.serializer(), channel, current, createdAt, citation, owner)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the community's disappearing-messages timer (CORD-08 §1) to [secs] seconds, or turns it
|
||||
* off when null. It is a metadata edition like any other — same chain, same MANAGE_METADATA
|
||||
* gate — laid over the folded [standing] metadata so nothing else changes.
|
||||
*/
|
||||
suspend fun setMessageExpiration(
|
||||
actor: NostrSigner,
|
||||
controlPlane: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
standing: MetadataEntity,
|
||||
secs: Long?,
|
||||
current: List<ControlEdition>,
|
||||
createdAt: Long,
|
||||
citation: AuthorityCitation? = null,
|
||||
owner: HexKey,
|
||||
): Event = editMetadata(actor, controlPlane, communityId, standing.withMessageExpiration(secs), current, createdAt, citation, owner)
|
||||
|
||||
/**
|
||||
* Replaces the community metadata (name / icon / description / relays). The
|
||||
* metadata entity id is the community id itself (as in genesis), so this chains
|
||||
* the next version onto the metadata head. Honored at fold only when [actor]
|
||||
* holds MANAGE_METADATA (or is the owner) tracing to the owner via [citation].
|
||||
* holds MANAGE_METADATA (or is the owner), and only within the CORD-02 §6 caps.
|
||||
*/
|
||||
suspend fun editMetadata(
|
||||
actor: NostrSigner,
|
||||
@@ -169,9 +223,9 @@ object ConcordModeration {
|
||||
citation: AuthorityCitation? = null,
|
||||
owner: HexKey,
|
||||
): Event {
|
||||
val (version, prev) = versioning(current, communityId, owner)
|
||||
val content = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), metadata)
|
||||
return wrap(actor, controlPlane, ControlEntityKind.METADATA, communityId, version, prev, content, createdAt, citation)
|
||||
require(ConcordLimits.nameFits(metadata.name)) { "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" }
|
||||
require(ConcordLimits.descriptionFits(metadata.description)) { "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes" }
|
||||
return edit(actor, controlPlane, communityId, ControlEntityKind.METADATA, communityId, MetadataEntity.serializer(), metadata, current, createdAt, citation, owner)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,10 +249,10 @@ object ConcordModeration {
|
||||
owner: HexKey,
|
||||
controlWrap: String? = null,
|
||||
): Event {
|
||||
require(roleIds.size <= ConcordLimits.MAX_ROLES_PER_MEMBER) { "a member holds at most ${ConcordLimits.MAX_ROLES_PER_MEMBER} roles" }
|
||||
val entityId = ConcordKeyDerivation.grantCoordinate(communityId, member.hexToByteArray())
|
||||
val (version, prev) = versioning(current, entityId, owner)
|
||||
val content = ConcordJson.instance.encodeToString(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds, controlWrap = controlWrap))
|
||||
return wrap(actor, controlPlane, ControlEntityKind.GRANT, entityId, version, prev, content, createdAt, citation)
|
||||
val grant = GrantEntity(member = member, roleIds = roleIds, controlWrap = controlWrap)
|
||||
return edit(actor, controlPlane, communityId, ControlEntityKind.GRANT, entityId, GrantEntity.serializer(), grant, current, createdAt, citation, owner)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,7 +281,7 @@ object ConcordModeration {
|
||||
epoch: Long,
|
||||
): Event {
|
||||
val wrap =
|
||||
if (controlRoot != null && makesStaff(roleIds, current, owner)) {
|
||||
if (controlRoot != null && makesStaff(roleIds, current, communityId, owner)) {
|
||||
ControlRootWrap.build(actor, member, epoch, controlRoot)
|
||||
} else {
|
||||
null
|
||||
@@ -239,14 +293,19 @@ object ConcordModeration {
|
||||
fun makesStaff(
|
||||
roleIds: List<String>,
|
||||
current: List<ControlEdition>,
|
||||
communityId: ByteArray,
|
||||
owner: HexKey,
|
||||
): Boolean {
|
||||
if (roleIds.isEmpty()) return false
|
||||
val roles = AuthorityResolver.resolve(current, owner).roles()
|
||||
val roles = AuthorityResolver.resolve(current, communityId, owner).roles()
|
||||
return roleIds.any { roles[it]?.permissionBits()?.hasAny(ConcordPermissions.STAFF_BITS) == true }
|
||||
}
|
||||
|
||||
/** Adds [member] to the banlist (union with the current head). */
|
||||
/**
|
||||
* Adds [member] to the banlist, written over the current folded head. Another admin's
|
||||
* concurrent edition at the same version may win the fold (CORD-04 §4); calling this again
|
||||
* after the refold re-applies the ban atop the winner — the spec's re-heal.
|
||||
*/
|
||||
suspend fun ban(
|
||||
actor: NostrSigner,
|
||||
controlPlane: ControlPlaneKeys,
|
||||
@@ -271,19 +330,19 @@ object ConcordModeration {
|
||||
): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId, owner) - member.lowercase(), current, createdAt, citation, owner)
|
||||
|
||||
/**
|
||||
* The current banlist union across the head editions (lowercase hex).
|
||||
* The current banlist (lowercase hex): the folded head's list.
|
||||
*
|
||||
* Read through the [AuthorityResolver] rather than by decoding the head's content directly, so
|
||||
* this is the *honored* banlist: the resolver heals concurrent forks into the union (CORD-04 §4
|
||||
* re-heal) and drops entries whose signer did not outrank them (§3's rank rule, enforced as a
|
||||
* delta rule). Decoding the raw head instead would make every ban/unban we author re-publish
|
||||
* entries our own fold refuses — laundering an unauthorized ban into a list signed by us.
|
||||
* this is the *honored* banlist: the resolver drops entries whose signer did not outrank them
|
||||
* (§3's rank rule, enforced as a delta rule). Decoding the raw head instead would make every
|
||||
* ban/unban we author re-publish entries our own fold refuses — laundering an unauthorized ban
|
||||
* into a list signed by us.
|
||||
*/
|
||||
fun currentBanned(
|
||||
current: List<ControlEdition>,
|
||||
communityId: ByteArray,
|
||||
owner: HexKey,
|
||||
): Set<HexKey> = AuthorityResolver.resolve(current, owner).bannedMembers()
|
||||
): Set<HexKey> = AuthorityResolver.resolve(current, communityId, owner).bannedMembers()
|
||||
|
||||
private suspend fun setBanlist(
|
||||
actor: NostrSigner,
|
||||
@@ -296,8 +355,8 @@ object ConcordModeration {
|
||||
owner: HexKey,
|
||||
): Event {
|
||||
val entityId = ConcordKeyDerivation.banlistCoordinate(communityId)
|
||||
val (version, prev) = versioning(current, entityId, owner)
|
||||
val head = headOf(current, communityId, entityId, owner)
|
||||
val content = ConcordJson.instance.encodeToString(ListSerializer(String.serializer()), banned.sorted())
|
||||
return wrap(actor, controlPlane, ControlEntityKind.BANLIST, entityId, version, prev, content, createdAt, citation)
|
||||
return wrap(actor, controlPlane, communityId, ControlEntityKind.BANLIST, entityId, head, content, current, createdAt, citation, owner)
|
||||
}
|
||||
}
|
||||
|
||||
+72
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.actions
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
@@ -29,6 +30,9 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRotationAuthority
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -106,6 +110,71 @@ object ConcordReceive {
|
||||
rotator: HexKey,
|
||||
): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN)
|
||||
|
||||
/**
|
||||
* Whether a received base rotation may be adopted (CORD-06 §3 "Authority"): its rotator holds
|
||||
* BAN (or is the owner) in our fold, AND it cites the Grant it acts under (`vac`) at a version
|
||||
* our fold has synced — so a just-demoted admin's rotation is never honored by a client that
|
||||
* lags the demotion, and a rotation citing a Grant we have not seen yet waits for it. The owner
|
||||
* cites nothing. [editions] are the current epoch's Control editions the citation is checked
|
||||
* against.
|
||||
*/
|
||||
fun isHonoredRotation(
|
||||
entry: ConcordCommunityListEntry,
|
||||
editions: Collection<ControlEdition>,
|
||||
authority: AuthorityResolver,
|
||||
received: ReceivedRefounding,
|
||||
): Boolean {
|
||||
if (!isAuthorizedRotator(authority, received.rotator)) return false
|
||||
val heads = ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner)
|
||||
return ConcordRotationAuthority.citationSatisfied(entry.id, received.rotator, entry.owner, received.authority, heads)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `vac` citation [actor] stamps on a rotation it launches (CORD-06 §3): their own Grant's
|
||||
* head in our fold, or null for the owner (who cites nothing).
|
||||
*/
|
||||
fun rotationCitation(
|
||||
entry: ConcordCommunityListEntry,
|
||||
editions: Collection<ControlEdition>,
|
||||
actor: HexKey,
|
||||
): AuthorityCitation? = ConcordRotationAuthority.citationFor(entry.id, actor, entry.owner, ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner))
|
||||
|
||||
/**
|
||||
* The down-only same-epoch heal (CORD-06 §3): [entry] holds a root at its current epoch, and
|
||||
* [sibling] is a rotation to that same epoch (from the same prior root) that we did not adopt.
|
||||
* Returns the entry moved onto the sibling when its root is **strictly lower** — the losing
|
||||
* (higher) root we held is kept as a held root of the same epoch, so the messages sent into
|
||||
* that fork stay readable — or null when the sibling does not win.
|
||||
*
|
||||
* The losing root keeps no control material: its Control Plane is the losing fork's
|
||||
* compaction, not the community's ([ConcordRefounding.canonicalHeldRoots] never folds it).
|
||||
*/
|
||||
fun withHealedRoot(
|
||||
entry: ConcordCommunityListEntry,
|
||||
sibling: ReceivedRefounding,
|
||||
): ConcordCommunityListEntry? {
|
||||
if (sibling.newEpoch != entry.rootEpoch) return null
|
||||
if (!ConcordRefounding.healsTo(entry.root.hexToByteArray(), sibling.newRoot)) return null
|
||||
return ConcordCommunityListEntry(
|
||||
id = entry.id,
|
||||
owner = entry.owner,
|
||||
ownerSalt = entry.ownerSalt,
|
||||
root = sibling.newRoot.toHexKey(),
|
||||
rootEpoch = entry.rootEpoch,
|
||||
// The control pair is the winner's blob's, never inherited from the losing fork.
|
||||
controlPk = sibling.newControlPk?.toHexKey(),
|
||||
controlRoot = sibling.newControlRoot?.toHexKey(),
|
||||
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch to it.key.lowercase() },
|
||||
privateChannels = entry.privateChannels,
|
||||
relays = entry.relays,
|
||||
name = entry.name,
|
||||
addedAt = entry.addedAt,
|
||||
inviteRef = entry.inviteRef,
|
||||
excludedAtEpoch = entry.excludedAtEpoch,
|
||||
residue = entry.residue,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry that results from adopting a base rotation to [newEpoch] — a pure rewrite, so the
|
||||
* caller can diff, persist and publish it however its platform does.
|
||||
@@ -133,7 +202,9 @@ object ConcordReceive {
|
||||
rootEpoch = newEpoch,
|
||||
controlPk = newControlPk?.toHexKey(),
|
||||
controlRoot = newControlRoot?.toHexKey(),
|
||||
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch },
|
||||
// Keyed by (epoch, key), not epoch alone: a healed race leaves a losing fork's root at the
|
||||
// same epoch, kept so its messages stay readable (CORD-06 §3).
|
||||
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch to it.key.lowercase() },
|
||||
privateChannels = entry.privateChannels,
|
||||
relays = entry.relays,
|
||||
name = entry.name,
|
||||
|
||||
+31
-17
@@ -25,7 +25,9 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -77,7 +79,9 @@ object ConcordSubscriptionPlanner {
|
||||
// the entry, per epoch, exactly as it was delivered.
|
||||
val cp = ConcordActions.controlPlaneKeysFor(e)
|
||||
val historical =
|
||||
e.heldRoots
|
||||
// Losing-fork roots of a healed race are kept for their messages only (CORD-06 §3).
|
||||
ConcordRefounding
|
||||
.canonicalHeldRoots(e.heldRoots)
|
||||
.filter { it.epoch < e.rootEpoch }
|
||||
.sortedByDescending { it.epoch }
|
||||
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
|
||||
@@ -92,7 +96,8 @@ object ConcordSubscriptionPlanner {
|
||||
* The off-channel planes every joined community subscribes to upfront (known
|
||||
* from the entry alone): the Guestbook Plane (membership motions) and the
|
||||
* next-epoch base-rekey address (so an inbound Refounding is received live,
|
||||
* CORD-06). Both are kind-1059 wraps authored by their derived stream address.
|
||||
* CORD-06), and the dissolution tombstone address (CORD-02 §9). All are kind-1059
|
||||
* wraps authored by their derived stream address.
|
||||
*/
|
||||
fun auxiliaryPlaneSubs(entries: List<ConcordCommunityListEntry>): List<ConcordPlaneSub> =
|
||||
entries.flatMap { e ->
|
||||
@@ -101,9 +106,14 @@ object ConcordSubscriptionPlanner {
|
||||
val relays = normalize(e.relays)
|
||||
val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch)
|
||||
val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch)
|
||||
listOf(
|
||||
val dissolved = ConcordDissolution.planeKey(e.id)
|
||||
val sibling = ConcordActions.siblingBaseRekeyPlane(e)
|
||||
listOfNotNull(
|
||||
ConcordPlaneSub(channelId = null, pubKeyHex = guestbook.publicKeyHex, relays = relays),
|
||||
ConcordPlaneSub(channelId = null, pubKeyHex = nextRekey.publicKeyHex, relays = relays),
|
||||
ConcordPlaneSub(channelId = null, pubKeyHex = dissolved.publicKeyHex, relays = relays),
|
||||
// The current epoch's own rekey address, so a racing sibling can heal us (CORD-06 §3).
|
||||
sibling?.let { ConcordPlaneSub(channelId = null, pubKeyHex = it.publicKeyHex, relays = relays) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,24 +128,28 @@ object ConcordSubscriptionPlanner {
|
||||
entry: ConcordCommunityListEntry,
|
||||
state: ConcordCommunityState,
|
||||
): List<ConcordPlaneSub> {
|
||||
val root = entry.root.hexToByteArray()
|
||||
val relays = normalize(entry.relays)
|
||||
// A Private Channel is subscribed on its own key's plane only, and not at all without a held
|
||||
// key (CORD-03 §1): never on the root-derived plane every member can read.
|
||||
val current =
|
||||
state.channels.keys.map { channelIdHex ->
|
||||
val ch = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
ConcordPlaneSub(
|
||||
channelId = ConcordChannelId(entry.id, channelIdHex),
|
||||
pubKeyHex = ch.publicKeyHex,
|
||||
relays = relays,
|
||||
)
|
||||
state.channels.values.mapNotNull { channel ->
|
||||
ConcordActions.currentChannelPlane(entry, channel.channelIdHex, channel.definition.private)?.let { plane ->
|
||||
ConcordPlaneSub(
|
||||
channelId = ConcordChannelId(entry.id, plane.channelIdHex),
|
||||
pubKeyHex = plane.key.publicKeyHex,
|
||||
relays = relays,
|
||||
)
|
||||
}
|
||||
}
|
||||
val historical =
|
||||
ConcordActions.historicalChannelPlanes(entry.heldRoots, state.channels.keys).map { plane ->
|
||||
ConcordPlaneSub(
|
||||
channelId = ConcordChannelId(entry.id, plane.channelIdHex),
|
||||
pubKeyHex = plane.key.publicKeyHex,
|
||||
relays = relays,
|
||||
)
|
||||
state.channels.values.flatMap { channel ->
|
||||
ConcordActions.historicalChannelPlanes(entry, channel.channelIdHex, channel.definition.private).map { plane ->
|
||||
ConcordPlaneSub(
|
||||
channelId = ConcordChannelId(entry.id, plane.channelIdHex),
|
||||
pubKeyHex = plane.key.publicKeyHex,
|
||||
relays = relays,
|
||||
)
|
||||
}
|
||||
}
|
||||
return current + historical
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.commons.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
|
||||
@@ -783,7 +784,9 @@ class Account(
|
||||
// Invalidate the channel's metadata flow only on a real change so the Messages-row
|
||||
// name + community chip recompose when the fold first resolves them (they observe
|
||||
// metadata.stateFlow via observeChannel), without churning every row every tick.
|
||||
if (channel.updateFrom(state, relays, myPubKey)) channel.updateChannelInfo()
|
||||
// A Private Channel is readable/postable only with its held key (CORD-03 §1).
|
||||
val keyHeld = ConcordActions.canAccessChannel(session.entry, state, channelIdHex)
|
||||
if (channel.updateFrom(state, relays, myPubKey, keyHeld)) channel.updateChannelInfo()
|
||||
channel.notes
|
||||
.filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true }
|
||||
.forEach { channel.removeNote(it) }
|
||||
@@ -1747,11 +1750,18 @@ class Account(
|
||||
|
||||
// Marmot messages are retracted inside their group. A public NIP-09 here would e-tag
|
||||
// the group's private rumor ids onto public relays.
|
||||
val (marmotNotes, otherNotes) = notes.partition { marmot.marmotGroupOf(it) != null }
|
||||
val (marmotNotes, nonMarmotNotes) = notes.partition { marmot.marmotGroupOf(it) != null }
|
||||
marmotNotes.groupBy { marmot.marmotGroupOf(it)!! }.forEach { (groupId, groupNotes) ->
|
||||
marmot.deleteMarmotMessages(groupId, groupNotes)
|
||||
}
|
||||
|
||||
// Concord rumors are retracted inside their channel's plane (CORD-01 Deletions), for the
|
||||
// same reason: any other route carries the community's rumor ids outside it.
|
||||
val (concordNotes, otherNotes) = nonMarmotNotes.partition { concord.concordChannelOf(it) != null }
|
||||
concordNotes.groupBy { concord.concordChannelOf(it)!! }.forEach { (channel, channelNotes) ->
|
||||
concord.deleteConcordRumors(channel, channelNotes)
|
||||
}
|
||||
|
||||
val (myRumors, myNotes) =
|
||||
otherNotes
|
||||
.filter { it.author == userProfile() && it.event != null }
|
||||
@@ -1796,6 +1806,13 @@ class Account(
|
||||
return
|
||||
}
|
||||
|
||||
// In a Concord channel it is an in-channel kind-5 on the channel's plane (CORD-01), never a
|
||||
// NIP-17 DM to the p-tagged users, which would leak the rumor ids outside the community.
|
||||
concord.concordChannelOf(target)?.let { channel ->
|
||||
concord.deleteConcordRumors(channel, notes)
|
||||
return
|
||||
}
|
||||
|
||||
val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event }
|
||||
if (myRumors.isEmpty()) return
|
||||
|
||||
|
||||
+494
-165
File diff suppressed because it is too large
Load Diff
+14
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
@@ -211,6 +212,8 @@ class AccountSettings(
|
||||
var backupEphemeralChatList: EphemeralChatListEvent? = null,
|
||||
var backupRelayGroupList: SimpleGroupListEvent? = null,
|
||||
var backupConcordList: ConcordCommunityListEvent? = null,
|
||||
/** The newest kind-33302 Community List fragment per index (CORD-02 §8). */
|
||||
var backupConcordListFragments: List<ConcordCommunityListFragmentEvent> = emptyList(),
|
||||
var backupTrustProviderList: TrustProviderListEvent? = null,
|
||||
var backupCashuWallet: CashuWalletEvent? = null,
|
||||
var backupNutzapInfo: NutzapInfoEvent? = null,
|
||||
@@ -1381,6 +1384,17 @@ class AccountSettings(
|
||||
|
||||
override fun concordList() = backupConcordList
|
||||
|
||||
override fun concordListFragments() = backupConcordListFragments
|
||||
|
||||
override fun updateConcordListFragmentTo(fragment: ConcordCommunityListFragmentEvent) {
|
||||
val index = fragment.index() ?: return
|
||||
val prior = backupConcordListFragments.firstOrNull { it.index() == index }
|
||||
// Newest copy per index, as a relay resolves the coordinate (ties to the lower id).
|
||||
if (prior != null && (prior.createdAt > fragment.createdAt || (prior.createdAt == fragment.createdAt && prior.id <= fragment.id))) return
|
||||
backupConcordListFragments = backupConcordListFragments.filterNot { it.index() == index } + fragment
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) {
|
||||
// The joined list lives entirely in NIP-44-encrypted content (secrets),
|
||||
// so an empty `tags` is NOT an empty list — guard only on null.
|
||||
|
||||
+9
@@ -137,6 +137,8 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
|
||||
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||
import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent
|
||||
@@ -970,6 +972,11 @@ open class EventCache :
|
||||
rumor: Event,
|
||||
seenOnRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
) {
|
||||
// Defense in depth behind the session's Chat ingest gate: a channel plane carries Chat kinds
|
||||
// only (CORD-02 Appendix B). Another plane's kind — a Control edition, a Guestbook motion, a
|
||||
// rekey blob — must never land in the store as if it came from its own plane.
|
||||
if (!ChannelChat.isChatKind(rumor.kind)) return
|
||||
|
||||
// Attach to the channel BEFORE justConsume sets the event and notifies feeds,
|
||||
// so the note already carries its ConcordChannel gatherer when it flows through
|
||||
// the Messages-list incremental filter (which routes rows by that gatherer).
|
||||
@@ -3837,6 +3844,8 @@ open class EventCache :
|
||||
// so — exactly like the 10009 list above — it must be stored replaceably or the Concord
|
||||
// hub stays empty even after the event arrives.
|
||||
is ConcordCommunityListEvent,
|
||||
// Its successor (CORD-02 §8): the List split into addressable fragments at d = index.
|
||||
is ConcordCommunityListFragmentEvent,
|
||||
// The relay-signed NIP-29 39004 AV-participants addressable is durable group state.
|
||||
is GroupParticipantsEvent,
|
||||
is ExternalIdentitiesEvent,
|
||||
|
||||
+16
-7
@@ -52,10 +52,15 @@ class ConcordChannel(
|
||||
var channelName: String? = null
|
||||
private set
|
||||
|
||||
var isVoice: Boolean = false
|
||||
var isPrivate: Boolean = false
|
||||
private set
|
||||
|
||||
var isPrivate: Boolean = false
|
||||
/**
|
||||
* False for a Private Channel whose independent key this account does not hold (CORD-03 §1):
|
||||
* its plane can be neither read nor written, and it must never fall back to the root-derived
|
||||
* plane every member can decrypt. Always true for a Public Channel.
|
||||
*/
|
||||
var keyHeld: Boolean = true
|
||||
private set
|
||||
|
||||
/** The parent community's display name, from its folded metadata. */
|
||||
@@ -110,30 +115,30 @@ class ConcordChannel(
|
||||
state: ConcordCommunityState,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
myPubKey: HexKey,
|
||||
keyHeld: Boolean = true,
|
||||
): Boolean {
|
||||
val def = state.channels[channelId.channelId]?.definition
|
||||
// Channel fields keep their prior value until the channel edition folds.
|
||||
val newChannelName = def?.name ?: channelName
|
||||
val newVoice = def?.voice ?: isVoice
|
||||
val newPrivate = def?.private ?: isPrivate
|
||||
val newCommunityName = state.metadata?.name
|
||||
val newCommunityIcon = state.metadata?.icon
|
||||
val newCommunityBanner = state.metadata?.banner
|
||||
val newMembership = ConcordMembership.of(state.authority, myPubKey)
|
||||
val newDissolved = state.dissolved
|
||||
val newKeyHeld = !newPrivate || keyHeld
|
||||
|
||||
val changed =
|
||||
channelName != newChannelName ||
|
||||
isVoice != newVoice ||
|
||||
isPrivate != newPrivate ||
|
||||
communityName != newCommunityName ||
|
||||
communityIcon != newCommunityIcon ||
|
||||
communityBanner != newCommunityBanner ||
|
||||
membership != newMembership ||
|
||||
dissolved != newDissolved
|
||||
dissolved != newDissolved ||
|
||||
this.keyHeld != newKeyHeld
|
||||
|
||||
channelName = newChannelName
|
||||
isVoice = newVoice
|
||||
isPrivate = newPrivate
|
||||
communityName = newCommunityName
|
||||
communityIcon = newCommunityIcon
|
||||
@@ -141,6 +146,7 @@ class ConcordChannel(
|
||||
communityRelays = relays
|
||||
membership = newMembership
|
||||
dissolved = newDissolved
|
||||
this.keyHeld = newKeyHeld
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -154,8 +160,11 @@ class ConcordChannel(
|
||||
* ([ConcordMembership.isMember]) **and** the community must not have been dissolved (CORD-02 §9 —
|
||||
* a tombstone seals it read-only for everyone). Deleting one's own past message stays allowed even
|
||||
* after dissolution and does not go through this gate.
|
||||
*
|
||||
* A Private Channel whose key this account does not hold is never postable ([keyHeld]): there
|
||||
* is no plane to write to that only its members can read.
|
||||
*/
|
||||
fun canPost(): Boolean = membership.isMember() && !dissolved
|
||||
fun canPost(): Boolean = membership.isMember() && !dissolved && keyHeld
|
||||
|
||||
// Synthetic note representing this channel in the Messages list before any
|
||||
// message has loaded (so a just-joined channel appears immediately). Mirrors
|
||||
|
||||
+197
-48
@@ -20,44 +20,72 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.concord
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withAddedAt
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDocument
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListIncompleteException
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListResidue
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/** Persistence hook for the last-known kind 13302 event (offline backup). */
|
||||
/**
|
||||
* Persistence hook for the account's Community List (offline backup): the kind-33302 fragments
|
||||
* (CORD-02 §8) plus the retired kind-13302 single event, which is still read as a rescue source.
|
||||
*/
|
||||
interface ConcordListRepository {
|
||||
fun concordList(): ConcordCommunityListEvent?
|
||||
|
||||
fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?)
|
||||
|
||||
fun concordListFragments(): List<ConcordCommunityListFragmentEvent> = emptyList()
|
||||
|
||||
fun updateConcordListFragmentTo(fragment: ConcordCommunityListFragmentEvent) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's home base for Concord Channels: the kind-13302
|
||||
* [ConcordCommunityListEvent] (self-encrypted joined-communities list). This is
|
||||
* the Concord analog of NIP-29's [RelayGroupListState], but the entries carry the
|
||||
* community secrets (root/salt/epoch/private-channel keys), so decryption yields
|
||||
* everything needed to re-derive each plane on any device.
|
||||
* The account's home base for Concord Channels: the member's Community List (CORD-02 §8) — kind
|
||||
* 33302 fragments, NIP-44-encrypted to self, at `d` = the fragment index. The entries carry the
|
||||
* community secrets (root/salt/epoch/private-channel keys), so decryption yields everything needed
|
||||
* to re-derive each plane on any device.
|
||||
*
|
||||
* Exposes [liveCommunities] (the joined [ConcordCommunityListEntry] set) and
|
||||
* [liveServers] (the distinct community ids — the "server" rail). [follow]/
|
||||
* [unfollow] read-modify-write the list; the caller publishes the returned event.
|
||||
* The List reads as the union of every fragment below the declared count, merged with the retired
|
||||
* kind-13302 event if one exists — so a membership only the old event carries is still joined, and
|
||||
* the next write migrates it into the fragments. Nothing ever writes 13302 again.
|
||||
*
|
||||
* Exposes [liveCommunities] (the joined [ConcordCommunityListEntry] set) and [liveServers] (the
|
||||
* distinct community ids — the "server" rail). [follow]/[unfollow] read-modify-write the List and
|
||||
* return the fragment events to publish: a full repack when every fragment is held, or a write
|
||||
* scoped to the fragment holding the change when some are still missing (so a fragment stranded on
|
||||
* an unreachable relay never blocks a join or a leave).
|
||||
*/
|
||||
class ConcordChannelListState(
|
||||
val signer: NostrSigner,
|
||||
@@ -65,35 +93,103 @@ class ConcordChannelListState(
|
||||
val scope: CoroutineScope,
|
||||
val settings: ConcordListRepository,
|
||||
) {
|
||||
// Long-term reference so the GC doesn't collect the note itself.
|
||||
// Long-term references so the GC doesn't collect the notes themselves.
|
||||
val concordListNote = cache.getOrCreateAddressableNote(getConcordListAddress())
|
||||
private val fragmentNotes = HashMap<Int, AddressableNote>()
|
||||
private val fragmentNotesLock = KmpLock()
|
||||
|
||||
/** How many fragment coordinates we watch: at least index 0, and every index the List declares. */
|
||||
private val watchedFragments = MutableStateFlow(1)
|
||||
|
||||
/** Serializes read-modify-writes so two quick edits can't both build on the same base. */
|
||||
private val writeLock = Mutex()
|
||||
|
||||
/**
|
||||
* Plaintext per fragment/legacy event id. Every List change re-reads the whole List, and each
|
||||
* decrypt is a signer round trip (an IPC hop to Amber, a relay hop to a bunker); an event id's
|
||||
* plaintext never changes, so each is decrypted once. Failures are not cached, so a transient
|
||||
* signer error is retried on the next read.
|
||||
*/
|
||||
private val plaintextById = LinkedHashMap<String, String>()
|
||||
private val plaintextLock = KmpLock()
|
||||
|
||||
private suspend fun plaintextOf(
|
||||
id: String,
|
||||
decrypt: suspend () -> String?,
|
||||
): String? {
|
||||
plaintextLock.withLock { plaintextById[id] }?.let { return it }
|
||||
val plaintext = decrypt() ?: return null
|
||||
plaintextLock.withLock {
|
||||
plaintextById[id] = plaintext
|
||||
// Bounded: only the newest copy per index is ever read, so a few dozen ids is plenty.
|
||||
while (plaintextById.size > MAX_CACHED_PLAINTEXTS) plaintextById.remove(plaintextById.keys.first())
|
||||
}
|
||||
return plaintext
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the relays have been asked for this account's fragments since start-up
|
||||
* ([markRelaysConfirmed], after the import fetch). Until then an empty fragment set means
|
||||
* "not loaded yet", not "no List", and a write would replace fragments another device or
|
||||
* client published — so [follow]/[unfollow] refuse rather than guess.
|
||||
*/
|
||||
@Volatile
|
||||
var relaysConfirmed = false
|
||||
private set
|
||||
|
||||
fun markRelaysConfirmed() {
|
||||
relaysConfirmed = true
|
||||
}
|
||||
|
||||
/** The retired single-event list's coordinate, still read for migration. */
|
||||
fun getConcordListAddress() = ConcordCommunityListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getConcordListFlow(): StateFlow<NoteState> = concordListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getConcordList(): ConcordCommunityListEvent? = concordListNote.event as? ConcordCommunityListEvent
|
||||
|
||||
/**
|
||||
* Decrypts the current list (or the offline backup) into the full document — entries plus
|
||||
* the residue (unknown keys, tombstones) a read-modify-write has to hand back untouched.
|
||||
*/
|
||||
suspend fun documentWithBackup(note: Note): ConcordCommunityListDocument {
|
||||
val event = note.event as? ConcordCommunityListEvent ?: settings.concordList()
|
||||
return event?.decryptDocument(signer) ?: ConcordCommunityListDocument(emptyList())
|
||||
private fun fragmentNote(index: Int): AddressableNote =
|
||||
fragmentNotesLock.withLock {
|
||||
fragmentNotes.getOrPut(index) { cache.getOrCreateAddressableNote(ConcordCommunityListFragmentEvent.createAddress(signer.pubKey, index)) }
|
||||
}
|
||||
|
||||
/** Every fragment event we hold, from the cache and the offline backup (newest per index wins later). */
|
||||
private fun heldFragments(): List<ConcordCommunityListFragmentEvent> {
|
||||
val fromCache = (0 until watchedFragments.value).mapNotNull { fragmentNote(it).event as? ConcordCommunityListFragmentEvent }
|
||||
return fromCache + settings.concordListFragments()
|
||||
}
|
||||
|
||||
/** Decrypts the current list (or the offline backup) into its entries. */
|
||||
suspend fun entriesWithBackup(note: Note): List<ConcordCommunityListEntry> = documentWithBackup(note).entries
|
||||
/** Resolves the fragments we hold, widening the watch when the List declares more of them. */
|
||||
suspend fun fragmentSet(): ConcordListFragmentSet {
|
||||
val set = ConcordListFragmentSet.resolve(heldFragments(), signer.pubKey) { e -> plaintextOf(e.id) { e.decryptPlaintext(signer) } }
|
||||
if (set.declared > watchedFragments.value) watchedFragments.value = set.declared
|
||||
return set
|
||||
}
|
||||
|
||||
/** The fragments plus the merged, decoded List a read-modify-write starts from. */
|
||||
private suspend fun snapshot(): Pair<ConcordListFragmentSet, ConcordCommunityListDocument> {
|
||||
val set = fragmentSet()
|
||||
val legacyEvent = getConcordList() ?: settings.concordList()
|
||||
val legacy = legacyEvent?.let { e -> plaintextOf(e.id) { e.decryptPlaintext(signer) } }
|
||||
return set to ConcordCommunityList.decodeDocument(ConcordCommunityList.readWithLegacy(set, legacy))
|
||||
}
|
||||
|
||||
/** The whole decoded List — entries plus the residue a read-modify-write must hand back. */
|
||||
suspend fun document(): ConcordCommunityListDocument = snapshot().second
|
||||
|
||||
/** The joined entries. */
|
||||
suspend fun entries(): List<ConcordCommunityListEntry> = document().entries
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val listChanges: Flow<Any> =
|
||||
watchedFragments.flatMapLatest { n ->
|
||||
combine((0 until n).map { fragmentNote(it).flow().metadata.stateFlow } + concordListNote.flow().metadata.stateFlow) { it }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveCommunities: StateFlow<List<ConcordCommunityListEntry>> =
|
||||
getConcordListFlow()
|
||||
.transformLatest { noteState ->
|
||||
emit(entriesWithBackup(noteState.note))
|
||||
}.onStart {
|
||||
emit(entriesWithBackup(concordListNote))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
listChanges
|
||||
.transformLatest { emit(entries()) }
|
||||
.onStart { emit(entries()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
@@ -108,38 +204,91 @@ class ConcordChannelListState(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
/** Add or replace [entry] (by community id) and return the new signed list event to publish. */
|
||||
suspend fun follow(entry: ConcordCommunityListEntry): ConcordCommunityListEvent {
|
||||
// Seed from the offline backup as well as the live cache event: the saved list is
|
||||
// consumed into the cache asynchronously in `init`, so a join that races that load
|
||||
// would otherwise start from an empty `current` and wipe every prior membership.
|
||||
val doc = documentWithBackup(concordListNote)
|
||||
val next = doc.entries.filterNot { it.id == entry.id } + entry
|
||||
return ConcordCommunityListEvent.create(signer, next, residue = doc.residue)
|
||||
/**
|
||||
* Encrypts and signs the fragments that make the wire hold [entries] + [residue]. The new
|
||||
* document replaces the current memberships wholesale (never a merge), so a same-epoch
|
||||
* update — a delivered `control_root`, a rename — can't lose the snapshot tie-break to the
|
||||
* state it is replacing.
|
||||
*/
|
||||
private suspend fun write(
|
||||
set: ConcordListFragmentSet,
|
||||
entries: List<ConcordCommunityListEntry>,
|
||||
residue: ConcordListResidue,
|
||||
): List<ConcordCommunityListFragmentEvent> {
|
||||
if (set.isEmpty && !relaysConfirmed) {
|
||||
throw ConcordListIncompleteException("the Community List has not been fetched from relays yet; refusing to overwrite it")
|
||||
}
|
||||
val newDoc = ConcordCommunityList.encodeInternal(entries, residue)
|
||||
return set.planWrites(newDoc, TimeUtils.now()).map { w ->
|
||||
ConcordCommunityListFragmentEvent.create(signer, w.index, w.plaintext, w.createdAt).also { settings.updateConcordListFragmentTo(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the community with [communityId] and return the new list event, or null if none existed. */
|
||||
suspend fun unfollow(communityId: String): ConcordCommunityListEvent? {
|
||||
val doc = documentWithBackup(concordListNote)
|
||||
if (doc.entries.none { it.id == communityId }) return null
|
||||
val next = doc.entries.filterNot { it.id == communityId }
|
||||
return ConcordCommunityListEvent.create(signer, next, residue = doc.residue)
|
||||
/**
|
||||
* Add or replace [entry] (by community id) and return the fragment events to publish.
|
||||
*
|
||||
* Adding a community we once left is a re-join, which must outrank the tombstone
|
||||
* (`added_at > removed_at`, CORD-02 §8): an entry that doesn't — a join in the same second as
|
||||
* the leave, or a stale snapshot — gets its `added_at` lifted just past the removal.
|
||||
*
|
||||
* Throws [ConcordListIncompleteException] when the List isn't loaded well enough to write
|
||||
* without destroying a fragment, and [ConcordListTooLargeException] when a fragment would
|
||||
* pass the event ceiling.
|
||||
*/
|
||||
suspend fun follow(entry: ConcordCommunityListEntry): List<Event> =
|
||||
writeLock.withLock {
|
||||
val (set, doc) = snapshot()
|
||||
val removedAt = doc.residue.removedAt(entry.id)
|
||||
val live = if (removedAt != null && entry.addedAt <= removedAt) entry.withAddedAt(maxOf(TimeUtils.nowMillis(), removedAt + 1)) else entry
|
||||
write(set, doc.entries.filterNot { it.id == entry.id } + live, doc.residue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave [communityId]: drop its membership and tombstone it (CORD-02 §8 — only a tombstone
|
||||
* subtracts a membership; a missing entry is just unseen news another fragment may still
|
||||
* carry). Returns the fragment events to publish, or empty when we were not a member.
|
||||
*/
|
||||
suspend fun unfollow(communityId: String): List<Event> =
|
||||
writeLock.withLock {
|
||||
val (set, doc) = snapshot()
|
||||
if (doc.entries.none { it.id == communityId }) return@withLock emptyList()
|
||||
write(set, doc.entries.filterNot { it.id == communityId }, doc.residue.withTombstone(communityId, TimeUtils.nowMillis()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the List as it currently reads — used to seed the fragments from the retired 13302
|
||||
* event once relays confirmed none exist yet. Empty when there is nothing to write.
|
||||
*/
|
||||
suspend fun republish(): List<Event> =
|
||||
writeLock.withLock {
|
||||
val (set, doc) = snapshot()
|
||||
if (doc.entries.isEmpty() && doc.residue.tombstones.isEmpty()) return@withLock emptyList()
|
||||
write(set, doc.entries, doc.residue)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_CACHED_PLAINTEXTS = 64
|
||||
}
|
||||
|
||||
init {
|
||||
settings.concordList()?.let { event ->
|
||||
val savedLegacy = settings.concordList()
|
||||
val savedFragments = settings.concordListFragments()
|
||||
if (savedLegacy != null || savedFragments.isNotEmpty()) {
|
||||
Log.d("AccountRegisterObservers", "Loading saved concord list")
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
savedLegacy?.let { cache.justConsumeMyOwnEvent(it) }
|
||||
savedFragments.forEach { cache.justConsumeMyOwnEvent(it) }
|
||||
if (savedFragments.isNotEmpty()) watchedFragments.value = maxOf(watchedFragments.value, savedFragments.mapNotNull { it.index() }.max() + 1)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "ConcordList Collector Start")
|
||||
getConcordListFlow().collect { noteState ->
|
||||
(noteState.note.event as? ConcordCommunityListEvent)?.let {
|
||||
settings.updateConcordListTo(it)
|
||||
listChanges.collect {
|
||||
getConcordList()?.let { settings.updateConcordListTo(it) }
|
||||
for (i in 0 until watchedFragments.value) {
|
||||
(fragmentNote(i).event as? ConcordCommunityListFragmentEvent)?.let { settings.updateConcordListFragmentTo(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+162
-47
@@ -20,16 +20,19 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.concord
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.actions.ChannelPlane
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
|
||||
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.cord06Rekey.ConcordRefounding
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
@@ -137,15 +140,41 @@ class ConcordCommunitySession(
|
||||
*/
|
||||
private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch)
|
||||
|
||||
/**
|
||||
* The rekey address the rotation INTO this epoch rode on (from the prior held root), or null
|
||||
* for a joiner who holds no prior root. A racing sibling rotation to this same epoch lands
|
||||
* here; the app drains it for the down-only heal (CORD-06 §3).
|
||||
*/
|
||||
private val siblingBaseRekeyKey: GroupKey? = ConcordActions.siblingBaseRekeyPlane(entry)
|
||||
|
||||
/**
|
||||
* The dissolution tombstone address (CORD-02 §9): derived from the community id alone, so it
|
||||
* is the same for every epoch and every member past or present.
|
||||
*/
|
||||
private val dissolvedKey: GroupKey = ConcordDissolution.planeKey(entry.id)
|
||||
|
||||
/**
|
||||
* Set once a valid owner tombstone bound to this community arrives at [dissolvedAddress]. One
|
||||
* way: there is no un-dissolve, so a later fold can never clear it.
|
||||
*/
|
||||
@Volatile
|
||||
private var dissolved = false
|
||||
|
||||
/** The Control Plane stream address to subscribe to (known from the entry alone). */
|
||||
val controlPlaneAddress: HexKey get() = controlKeys.address
|
||||
|
||||
/** The dissolution tombstone stream address to subscribe to (known from the community id alone). */
|
||||
val dissolvedAddress: HexKey get() = dissolvedKey.publicKeyHex
|
||||
|
||||
/** The Guestbook Plane stream address to subscribe to (known from the entry alone). */
|
||||
val guestbookAddress: HexKey get() = guestbookKey.publicKeyHex
|
||||
|
||||
/** The next-epoch base-rekey stream address to watch for an inbound Refounding. */
|
||||
val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex
|
||||
|
||||
/** The current epoch's own base-rekey address (see [siblingBaseRekeyKey]), or null. */
|
||||
val siblingBaseRekeyAddress: HexKey? get() = siblingBaseRekeyKey?.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.
|
||||
@@ -159,7 +188,9 @@ class ConcordCommunitySession(
|
||||
* survives a process restart without any new storage.
|
||||
*/
|
||||
private val historicalControlKeys: Map<HexKey, Pair<ControlPlaneKeys, Long>> =
|
||||
entry.heldRoots
|
||||
// Losing-fork roots of a healed race carry no Control Plane of the community's (CORD-06 §3).
|
||||
ConcordRefounding
|
||||
.canonicalHeldRoots(entry.heldRoots)
|
||||
.filter { it.epoch < entry.rootEpoch }
|
||||
.sortedByDescending { it.epoch }
|
||||
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
|
||||
@@ -221,15 +252,22 @@ class ConcordCommunitySession(
|
||||
private val channelWrapsById = HashMap<HexKey, LinkedHashMap<HexKey, Event>>() // channelIdHex -> (wrapId -> wrap)
|
||||
private val guestbookWraps = LinkedHashMap<HexKey, Event>()
|
||||
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
|
||||
private val siblingRekeyWraps = LinkedHashMap<HexKey, Event>()
|
||||
|
||||
// channel plane pubkey -> (channelIdHex, key), refreshed on each control re-fold.
|
||||
private var channelKeysByAddress = HashMap<HexKey, Pair<HexKey, GroupKey>>()
|
||||
// Current channel plane pubkey -> plane (channel id, key, bound epoch), refreshed on each control
|
||||
// re-fold. A Public Channel's plane derives from the root at the root epoch; a Private one's from
|
||||
// its held channel key at the channel epoch (CORD-03 §1). A Private Channel we hold no key for has
|
||||
// no entry at all: it is neither subscribed, read, nor written.
|
||||
private var channelKeysByAddress = HashMap<HexKey, ChannelPlane>()
|
||||
|
||||
// Prior-epoch channel plane pubkey -> (channelIdHex, key, epoch), for pre-Refounding history.
|
||||
// A CORD-06 Refounding rotates the root per epoch, so older messages live under a different
|
||||
// plane per held root; we re-derive those here so historical wraps are subscribed, AUTHed, and
|
||||
// decrypted alongside the current epoch. Empty when the account holds no prior roots.
|
||||
private var historicalChannelKeysByAddress = HashMap<HexKey, Triple<HexKey, GroupKey, Long>>()
|
||||
// Older channel plane pubkey -> plane, for history: a Public Channel's plane under each held
|
||||
// prior root (a CORD-06 Refounding rotates the root per epoch) plus its private-era plane when a
|
||||
// channel key is held. Subscribed, AUTHed and decrypted alongside the current planes.
|
||||
private var historicalChannelKeysByAddress = HashMap<HexKey, ChannelPlane>()
|
||||
|
||||
// The held Private Channel keys the current channel planes were derived from, so a later list
|
||||
// entry carrying different keys re-derives them (see [adoptPrivateChannels]).
|
||||
private var derivedPrivateKeys = privateKeySet(entry)
|
||||
|
||||
private val _state = MutableStateFlow<ConcordCommunityState?>(null)
|
||||
val state: StateFlow<ConcordCommunityState?> = _state
|
||||
@@ -311,11 +349,32 @@ class ConcordCommunitySession(
|
||||
address == controlPlaneAddress ||
|
||||
address == guestbookAddress ||
|
||||
address == nextBaseRekeyAddress ||
|
||||
address == siblingBaseRekeyAddress ||
|
||||
address == dissolvedAddress ||
|
||||
address in historicalControlKeys ||
|
||||
lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress }
|
||||
|
||||
/** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */
|
||||
fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key }
|
||||
fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.channelIdHex == channelIdHex }?.key }
|
||||
|
||||
/**
|
||||
* The plane [channelIdHex] is written on now, or null when it is not folded yet or is a Private
|
||||
* Channel this account holds no key for (CORD-03 §1) — the caller must then refuse to post.
|
||||
*/
|
||||
fun currentChannelPlane(channelIdHex: HexKey): ChannelPlane? = lock.withLock { channelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex } }
|
||||
|
||||
/**
|
||||
* The plane of [channelIdHex] bound to [epoch] — current or historical — or null when this
|
||||
* account holds none. A delete of an older message goes back onto the plane that carried it.
|
||||
*/
|
||||
fun channelPlaneFor(
|
||||
channelIdHex: HexKey,
|
||||
epoch: Long,
|
||||
): ChannelPlane? =
|
||||
lock.withLock {
|
||||
channelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex && it.epoch == epoch }
|
||||
?: historicalChannelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex && it.epoch == epoch }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Chat Plane stream address for [channelIdHex] across epochs: the current one plus each
|
||||
@@ -326,8 +385,8 @@ class ConcordCommunitySession(
|
||||
*/
|
||||
fun channelPlaneAddressesAllEpochs(channelIdHex: HexKey): List<HexKey> =
|
||||
lock.withLock {
|
||||
val current = channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key
|
||||
val historical = historicalChannelKeysByAddress.entries.filter { it.value.first == channelIdHex }.map { it.key }
|
||||
val current = channelKeysByAddress.entries.firstOrNull { it.value.channelIdHex == channelIdHex }?.key
|
||||
val historical = historicalChannelKeysByAddress.entries.filter { it.value.channelIdHex == channelIdHex }.map { it.key }
|
||||
(listOfNotNull(current) + historical)
|
||||
}
|
||||
|
||||
@@ -337,6 +396,12 @@ class ConcordCommunitySession(
|
||||
/** The buffered kind-3303 base-rotation wraps seen at [nextBaseRekeyAddress], for the account to drain. */
|
||||
fun pendingBaseRekeyWraps(): List<Event> = lock.withLock { baseRekeyWraps.values.toList() }
|
||||
|
||||
/** The base-rekey [GroupKey] of the rotation into this epoch (sibling heal), or null. */
|
||||
fun siblingBaseRekeyKey(): GroupKey? = siblingBaseRekeyKey
|
||||
|
||||
/** The buffered kind-3303 wraps seen at [siblingBaseRekeyAddress], for the account's heal drain. */
|
||||
fun pendingSiblingRekeyWraps(): List<Event> = lock.withLock { siblingRekeyWraps.values.toList() }
|
||||
|
||||
/**
|
||||
* Every stream key whose kind-1059 wraps this session reads: the Control Plane plus
|
||||
* one per folded channel. These are the identities a NIP-42 relay must see the
|
||||
@@ -362,13 +427,16 @@ class ConcordCommunitySession(
|
||||
// Prior-epoch Control Planes: the anti-rollback floor is folded from them, so the
|
||||
// gated relays must serve their wraps too.
|
||||
historicalControlKeys.values.mapNotNull { it.first.signer } +
|
||||
channelKeysByAddress.values.map { it.second } +
|
||||
channelKeysByAddress.values.map { it.key } +
|
||||
// Prior-epoch channel stream keys so the gated relays serve their older wraps too.
|
||||
historicalChannelKeysByAddress.values.map { it.second }
|
||||
historicalChannelKeysByAddress.values.map { it.key }
|
||||
}
|
||||
|
||||
/** The CORD-06 auxiliary plane keys (Guestbook + next base-rekey) for their own isolated AUTH. */
|
||||
fun auxStreamKeys(): List<GroupKey> = listOf(guestbookKey, nextBaseRekeyKey)
|
||||
/**
|
||||
* The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address)
|
||||
* for their own isolated AUTH.
|
||||
*/
|
||||
fun auxStreamKeys(): List<GroupKey> = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey)
|
||||
|
||||
/** The community's current Control Plane editions — the input a moderation edition chains onto. */
|
||||
fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) }
|
||||
@@ -414,6 +482,33 @@ class ConcordCommunitySession(
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a change to the Private Channel keys the Community List carries for this same community,
|
||||
* root and epoch (a key delivered on grant, CORD-03 §1): the entry is swapped in place and the
|
||||
* channel planes re-derived, so a newly held Private Channel is subscribed, read and written on
|
||||
* its own plane without dropping the buffered Control Plane wraps a rebuild would lose.
|
||||
*
|
||||
* Returns false, changing nothing, when [newEntry] is not the same community at the same root,
|
||||
* epoch and Control Plane material (the caller rebuilds, or adopts that first), or when the held
|
||||
* channel keys did not change.
|
||||
*/
|
||||
fun adoptPrivateChannels(newEntry: ConcordCommunityListEntry): Boolean {
|
||||
val changed =
|
||||
lock.withLock {
|
||||
val cur = entry
|
||||
if (newEntry.id != cur.id || newEntry.root != cur.root || newEntry.rootEpoch != cur.rootEpoch) return false
|
||||
if (newEntry.controlPk != cur.controlPk || newEntry.controlRoot != cur.controlRoot) return false
|
||||
// Compared with what the planes were derived from, not with [entry]: an adoption of
|
||||
// Control material may already have swapped in an entry carrying the new keys.
|
||||
if (privateKeySet(newEntry) == derivedPrivateKeys) return false
|
||||
entry = newEntry
|
||||
true
|
||||
}
|
||||
// Nothing folded yet: the first control wrap derives the planes from the swapped-in entry.
|
||||
if (changed && lock.withLock { controlWraps.isNotEmpty() }) refold()
|
||||
return changed
|
||||
}
|
||||
|
||||
/** This account's standing, from the current fold. */
|
||||
fun membership(): ConcordMembership {
|
||||
val s = _state.value ?: return ConcordMembership.MEMBER
|
||||
@@ -449,6 +544,17 @@ class ConcordCommunitySession(
|
||||
refoldGuestbook()
|
||||
return ConcordIngestOutcome.STRUCTURAL
|
||||
}
|
||||
dissolvedAddress -> {
|
||||
// Anyone holding the (public) community id can sign here, so only an owner-signed,
|
||||
// eid-bound tombstone counts (CORD-02 §9); everything else is noise we still claim.
|
||||
if (dissolved || !ConcordDissolution.isTombstoneWrap(wrap, entry.id, entry.owner)) return ConcordIngestOutcome.NON_STRUCTURAL
|
||||
lock.withLock {
|
||||
dissolved = true
|
||||
_state.value = _state.value?.withDissolved(true)
|
||||
}
|
||||
// The state watcher bumps the revision off the changed fold, as for a control wrap.
|
||||
return ConcordIngestOutcome.STRUCTURAL_FOLD
|
||||
}
|
||||
nextBaseRekeyAddress -> {
|
||||
// Buffer only — decrypting a base-rotation blob needs the account signer, so the
|
||||
// app layer drains [pendingBaseRekeyWraps] with it and authorizes the rotator. That
|
||||
@@ -457,6 +563,11 @@ class ConcordCommunitySession(
|
||||
lock.withLock { baseRekeyWraps[wrap.id] = wrap }
|
||||
return ConcordIngestOutcome.STRUCTURAL
|
||||
}
|
||||
siblingBaseRekeyAddress -> {
|
||||
// Same as above for a racing rotation into THIS epoch (the down-only heal).
|
||||
lock.withLock { siblingRekeyWraps[wrap.id] = wrap }
|
||||
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
|
||||
@@ -471,15 +582,13 @@ class ConcordCommunitySession(
|
||||
}
|
||||
val current = lock.withLock { channelKeysByAddress[wrap.pubKey] }
|
||||
if (current != null) {
|
||||
val (channelIdHex, key) = current
|
||||
return ingestChannelWrap(wrap, channelIdHex, key, entry.rootEpoch, seenOnRelays)
|
||||
return ingestChannelWrap(wrap, current.channelIdHex, current.key, current.epoch, seenOnRelays)
|
||||
}
|
||||
// A prior-epoch plane (pre-Refounding history). Decrypt with that epoch's key and
|
||||
// bind-check against that epoch. Keyed separately from the current buffer so a re-fold
|
||||
// (which rebuilds only the current-epoch keys) never re-projects the historical ones.
|
||||
// An older plane (pre-Refounding history, or a Public Channel's private era). Decrypt
|
||||
// with that plane's key and bind-check against its epoch. Keyed separately from the
|
||||
// current buffer so a re-fold never re-projects the historical ones.
|
||||
val historical = lock.withLock { historicalChannelKeysByAddress[wrap.pubKey] } ?: return ConcordIngestOutcome.NOT_MINE
|
||||
val (channelIdHex, key, epoch) = historical
|
||||
return ingestChannelWrap(wrap, channelIdHex, key, epoch, seenOnRelays)
|
||||
return ingestChannelWrap(wrap, historical.channelIdHex, historical.key, historical.epoch, seenOnRelays)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,8 +628,10 @@ class ConcordCommunitySession(
|
||||
key: GroupKey,
|
||||
epoch: Long,
|
||||
) {
|
||||
val rumor = ConcordStreamEnvelope.openOrNull(wrap, key)?.rumor ?: return
|
||||
if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, epoch)) return
|
||||
val opened = ConcordStreamEnvelope.openOrNull(wrap, key) ?: return
|
||||
// The same Chat gate as a stored rumor: encrypted seal, strict binding, well-formed ms.
|
||||
val rumor = ChannelChat.acceptOpened(opened, channelIdHex, epoch) ?: return
|
||||
if (!ChannelChat.isTyping(rumor)) return
|
||||
val who = rumor.pubKey.lowercase()
|
||||
if (who == myPubKey.lowercase()) return // never show my own typing back to me
|
||||
// A banned member's messages are dropped everywhere, so their typing heartbeat must be too —
|
||||
@@ -552,35 +663,37 @@ class ConcordCommunitySession(
|
||||
val folded =
|
||||
ConcordCommunityState.fold(
|
||||
editionsLocked(wraps, controlKeys),
|
||||
communityIdBytes,
|
||||
entry.owner,
|
||||
controlFloorsLocked(),
|
||||
)
|
||||
|
||||
val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first }
|
||||
val next = HashMap<HexKey, Pair<HexKey, GroupKey>>()
|
||||
for (channelIdHex in folded.channels.keys) {
|
||||
val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
next[key.publicKeyHex] = channelIdHex to key
|
||||
val prevAddresses = channelKeysByAddress.keys.toHashSet()
|
||||
val next = HashMap<HexKey, ChannelPlane>()
|
||||
// Re-derive the older planes for the same (epoch-invariant) channel ids, so older
|
||||
// history is subscribed/AUTHed/decrypted. Channels are known only after a fold, hence
|
||||
// derived here rather than up front.
|
||||
val historical = HashMap<HexKey, ChannelPlane>()
|
||||
for ((channelIdHex, channel) in folded.channels) {
|
||||
val isPrivate = channel.definition.private
|
||||
// Null for a Private Channel with no held key: never the root-derived plane.
|
||||
ConcordActions.currentChannelPlane(entry, channelIdHex, isPrivate)?.let { next[it.key.publicKeyHex] = it }
|
||||
for (plane in ConcordActions.historicalChannelPlanes(entry, channelIdHex, isPrivate)) {
|
||||
historical[plane.key.publicKeyHex] = plane
|
||||
}
|
||||
}
|
||||
channelKeysByAddress = next
|
||||
|
||||
// Re-derive the prior-epoch planes for the same (epoch-invariant) channel ids, so older
|
||||
// pre-Refounding history is subscribed/AUTHed/decrypted. Channels are known only after a
|
||||
// fold, hence derived here rather than up front.
|
||||
val historical = HashMap<HexKey, Triple<HexKey, GroupKey, Long>>()
|
||||
for (plane in ConcordActions.historicalChannelPlanes(entry.heldRoots, folded.channels.keys)) {
|
||||
historical[plane.key.publicKeyHex] = Triple(plane.channelIdHex, plane.key, plane.epoch)
|
||||
}
|
||||
historicalChannelKeysByAddress = historical
|
||||
derivedPrivateKeys = privateKeySet(entry)
|
||||
|
||||
_state.value = folded
|
||||
folded.channels.keys.filterNot { it in prevChannels }
|
||||
_state.value = folded.withDissolved(dissolved)
|
||||
next.filterKeys { it !in prevAddresses }.values.map { it.channelIdHex }
|
||||
}
|
||||
|
||||
// Project only channels appearing for the first time. Existing channels' wraps were already
|
||||
// emitted incrementally as they arrived (a channel plane is only subscribed after it folds, so
|
||||
// a channel's buffer never pre-dates its first fold) — re-projecting all channels on every
|
||||
// control edition would be O(channels × history) of redundant decryption.
|
||||
// Project only channels whose current plane is new (a first fold, or a plane that moved when a
|
||||
// Private Channel's key arrived). Existing planes' wraps were already emitted incrementally as
|
||||
// they arrived — re-projecting all channels on every control edition would be
|
||||
// O(channels × history) of redundant decryption.
|
||||
for (channelIdHex in newChannels) reprojectChannel(channelIdHex)
|
||||
}
|
||||
|
||||
@@ -596,7 +709,7 @@ class ConcordCommunitySession(
|
||||
if (editionByWrapId.containsKey(wrap.id)) {
|
||||
editionByWrapId[wrap.id]
|
||||
} else {
|
||||
val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKeys)?.let { ControlEdition.fromRumor(it.rumor) }
|
||||
val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKeys)?.let { ControlEdition.fromOpened(it) }
|
||||
editionByWrapId[wrap.id] = edition
|
||||
edition
|
||||
}
|
||||
@@ -621,7 +734,7 @@ class ConcordCommunitySession(
|
||||
val wraps = historicalControlWraps[address]?.values?.toList() ?: continue
|
||||
val editions = editionsLocked(wraps, keyAtEpoch.first)
|
||||
if (editions.isEmpty()) continue
|
||||
floors = ConcordCommunityState.authorizedHeads(editions, entry.owner, floors)
|
||||
floors = ConcordCommunityState.authorizedHeads(editions, communityIdBytes, entry.owner, floors)
|
||||
}
|
||||
return floors
|
||||
}
|
||||
@@ -645,9 +758,9 @@ class ConcordCommunitySession(
|
||||
* re-fold (keys may change). Prior-epoch wraps in the buffer simply won't open under the current
|
||||
* key and are skipped — they were already emitted when they landed (the sink dedups by id). */
|
||||
private fun reprojectChannel(channelIdHex: HexKey) {
|
||||
val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return
|
||||
val plane = currentChannelPlane(channelIdHex) ?: return
|
||||
val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return
|
||||
emitChannelRumors(channelIdHex, key, entry.rootEpoch, wraps)
|
||||
emitChannelRumors(channelIdHex, plane.key, plane.epoch, wraps)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -675,6 +788,8 @@ class ConcordCommunitySession(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun privateKeySet(e: ConcordCommunityListEntry) = e.privateChannels.mapTo(HashSet()) { Triple(it.channelId.lowercase(), it.key.lowercase(), it.epoch) }
|
||||
|
||||
/** A typing heartbeat is considered current for this many seconds after it's seen. */
|
||||
const val TYPING_STALE_SECS = 8L
|
||||
}
|
||||
|
||||
+8
-8
@@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
|
||||
/** What kind of plane an address belongs to. */
|
||||
enum class ConcordPlaneKind {
|
||||
@@ -105,17 +104,18 @@ class ConcordPlaneRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers the Chat Plane address of every channel in a folded community [state]. */
|
||||
/**
|
||||
* Registers the current Chat Plane address of every channel in a folded community [state] this
|
||||
* account can read: a Public Channel's root-derived plane, a Private Channel's held-key plane,
|
||||
* and nothing for a Private Channel whose key is not held (CORD-03 §1).
|
||||
*/
|
||||
fun registerChannels(
|
||||
entry: ConcordCommunityListEntry,
|
||||
state: ConcordCommunityState,
|
||||
) = lock.withLock {
|
||||
val root = entry.root.hexToByteArray()
|
||||
for (channelIdHex in state.channels.keys) {
|
||||
val ch =
|
||||
com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
|
||||
.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
planes[ch.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, channelIdHex), ch)
|
||||
for (channel in state.channels.values) {
|
||||
val plane = ConcordActions.currentChannelPlane(entry, channel.channelIdHex, channel.definition.private) ?: continue
|
||||
planes[plane.key.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, plane.channelIdHex), plane.key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -85,6 +85,9 @@ class ConcordSessionRegistry(
|
||||
// buffered wraps and fold the community empty, and the plane's address is
|
||||
// invariant under adoption anyway (CORD-02 §5).
|
||||
existing.adoptControlMaterial(entry)
|
||||
// Likewise a Private Channel key delivered on grant (CORD-03 §1): re-derive the
|
||||
// channel planes in place so the channel becomes readable and writable.
|
||||
existing.adoptPrivateChannels(entry)
|
||||
}
|
||||
}
|
||||
created
|
||||
|
||||
+6
-1
@@ -41,6 +41,8 @@ import okio.IOException
|
||||
*/
|
||||
enum class LatestEventSlot(
|
||||
val prefKey: String,
|
||||
/** False for a slot added after the SharedPreferences era: there is nothing to migrate. */
|
||||
val existedInLegacyPrefs: Boolean = true,
|
||||
) {
|
||||
CONTACT_LIST("latestContactList"),
|
||||
USER_METADATA("latestUserMetadata"),
|
||||
@@ -61,6 +63,9 @@ enum class LatestEventSlot(
|
||||
EPHEMERAL_LIST("latestEphemeralChatList"),
|
||||
RELAY_GROUP_LIST("latestRelayGroupList"),
|
||||
CONCORD_LIST("latestConcordList"),
|
||||
|
||||
/** The kind-33302 Community List fragments (CORD-02 §8), one event JSON per line. */
|
||||
CONCORD_LIST_FRAGMENTS("latestConcordListFragments", existedInLegacyPrefs = false),
|
||||
TRUST_PROVIDER_LIST("latestTrustProviderList"),
|
||||
KEY_PACKAGE_RELAY_LIST("latestKeyPackageRelayList"),
|
||||
FAVORITE_ALGO_FEEDS_LIST("latestFavoriteAlgoFeedsList"),
|
||||
@@ -94,7 +99,7 @@ class LatestEventCacheStore(
|
||||
val legacyTable =
|
||||
LegacyKeyTable(
|
||||
"migrated.latestEvents",
|
||||
LatestEventSlot.entries.map { LegacyStringKey(it.prefKey, it.key) },
|
||||
LatestEventSlot.entries.filter { it.existedInLegacyPrefs }.map { LegacyStringKey(it.prefKey, it.key) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterUserAsser
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
@@ -98,6 +99,7 @@ val AccountInfoAndListsFromKeyKinds2 =
|
||||
// Armada reference client, sharing this key) surface in the Concord hub at login,
|
||||
// instead of only appearing after creating/redeeming an invite in Amethyst itself.
|
||||
ConcordCommunityListEvent.KIND,
|
||||
ConcordCommunityListFragmentEvent.KIND,
|
||||
// NIP-60 Cashu wallet + NIP-61 nutzap info. Replaceables, always
|
||||
// useful to have available — wallet event holds the user's P2PK key
|
||||
// + mint list, nutzap info tells other clients which mints to lock
|
||||
|
||||
+4
-3
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.actions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
@@ -38,7 +39,7 @@ class ConcordActionsTest {
|
||||
val community = ConcordActions.createCommunity(owner, "Test Server", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
|
||||
// Fold genesis -> live state
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
|
||||
assertEquals("Test Server", state.metadata?.name)
|
||||
assertTrue(state.channels.containsKey(community.generalChannelIdHex))
|
||||
|
||||
@@ -78,7 +79,7 @@ class ConcordActionsTest {
|
||||
|
||||
// The joiner can derive the control plane and read the genesis.
|
||||
val controlPlane = ConcordActions.controlPlaneFor(opened)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, controlPlane, opened.owner)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, controlPlane, opened.communityId.hexToByteArray(), opened.owner)
|
||||
assertEquals("Nostrichs", state.metadata?.name)
|
||||
}
|
||||
|
||||
@@ -149,7 +150,7 @@ class ConcordActionsTest {
|
||||
rootEpoch = aliceGot.newEpoch,
|
||||
controlPk = deliveredControlPk.toHexKey(),
|
||||
)
|
||||
val state = ConcordActions.foldCommunity(build.controlWraps, newControl, community.ownerPubKey)
|
||||
val state = ConcordActions.foldCommunity(build.controlWraps, newControl, community.communityId, community.ownerPubKey)
|
||||
assertEquals("Test", state.metadata?.name)
|
||||
assertTrue(state.channels.isNotEmpty())
|
||||
}
|
||||
|
||||
+131
-34
@@ -29,13 +29,18 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
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.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
@@ -70,7 +75,7 @@ class ConcordModerationTest {
|
||||
position = 1,
|
||||
permissions = ConcordPermissions.of(ConcordPermissions.BAN, ConcordPermissions.KICK).toWire(),
|
||||
)
|
||||
add(ConcordModeration.defineRole(owner, cp, roleId, adminRole, editions, createdAt = 2L, owner = community.ownerPubKey))
|
||||
add(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, adminRole, editions, createdAt = 2L, owner = community.ownerPubKey))
|
||||
|
||||
// Owner grants that role to the admin user.
|
||||
add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, listOf(roleIdHex), editions, createdAt = 3L, owner = community.ownerPubKey))
|
||||
@@ -78,7 +83,7 @@ class ConcordModerationTest {
|
||||
// Owner bans the troll.
|
||||
add(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 4L, owner = community.ownerPubKey))
|
||||
|
||||
val state: ConcordCommunityState = ConcordCommunityState.fold(editions, community.ownerPubKey)
|
||||
val state: ConcordCommunityState = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey)
|
||||
|
||||
// The role exists, the admin holds BAN + the role id, and the troll is banned.
|
||||
assertTrue(state.roles.containsKey(roleIdHex))
|
||||
@@ -89,19 +94,19 @@ class ConcordModerationTest {
|
||||
|
||||
// Revoking (an empty grant, as "Remove admin" does) strips the role and its permissions.
|
||||
add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, emptyList(), editions, createdAt = 7L, owner = community.ownerPubKey))
|
||||
val demoted = ConcordCommunityState.fold(editions, community.ownerPubKey)
|
||||
val demoted = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey)
|
||||
assertFalse(demoted.authority.effectivePermissions(admin.pubKey).has(ConcordPermissions.BAN))
|
||||
assertTrue(demoted.authority.rolesOf(admin.pubKey).isEmpty())
|
||||
|
||||
// Unbanning the troll clears the flag (version chains onto the ban).
|
||||
add(ConcordModeration.unban(owner, cp, communityId, troll.pubKey, editions, createdAt = 5L, owner = community.ownerPubKey))
|
||||
val healed = ConcordCommunityState.fold(editions, community.ownerPubKey)
|
||||
val healed = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey)
|
||||
assertFalse(healed.authority.isBanned(troll.pubKey))
|
||||
|
||||
// A grant forged by the troll (who outranks nobody) is dropped by the fold.
|
||||
val forged = ConcordModeration.grant(troll, cp, communityId, troll.pubKey, listOf(roleIdHex), editions, createdAt = 6L, owner = community.ownerPubKey)
|
||||
val forgedEditions: List<ControlEdition> = editions + ConcordActions.controlEditions(listOf(forged), cp)
|
||||
val afterForgery = ConcordCommunityState.fold(forgedEditions, community.ownerPubKey)
|
||||
val afterForgery = ConcordCommunityState.fold(forgedEditions, community.communityId, community.ownerPubKey)
|
||||
assertFalse(afterForgery.authority.effectivePermissions(troll.pubKey).has(ConcordPermissions.BAN))
|
||||
}
|
||||
|
||||
@@ -115,8 +120,8 @@ class ConcordModerationTest {
|
||||
* masked by the down-only healing union, but an unban is not, so an unban simply
|
||||
* failed to apply.
|
||||
*
|
||||
* Here the banlist has two editions (v0 bans [troll], v1 also bans [stranger]) before
|
||||
* the unban. The unban must chain off v1 — the folded head — at v2, not off v0.
|
||||
* Here the banlist has two editions (v1 bans [troll], v2 also bans [stranger]) before
|
||||
* the unban. The unban must chain off v2 — the folded head — at v3, not off v1.
|
||||
*/
|
||||
@Test
|
||||
fun thirdBanlistEditionChainsOffTheFoldedHeadNotTheFirstArrival() =
|
||||
@@ -127,36 +132,36 @@ class ConcordModerationTest {
|
||||
|
||||
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
|
||||
|
||||
// v0: ban the troll. v1: ban the stranger too (chains onto v0).
|
||||
// v1: ban the troll. v2: ban the stranger too (chains onto v1). Versions start at 1 (CORD-04 §1).
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, stranger.pubKey, editions, createdAt = 3L, owner = community.ownerPubKey)), cp)
|
||||
|
||||
val banlistSoFar = editions.filter { it.entityKind == ControlEntityKind.BANLIST }
|
||||
assertEquals(2, banlistSoFar.size)
|
||||
val head = EditionFold.foldEntity(banlistSoFar)!!
|
||||
assertEquals(1L, head.version)
|
||||
// The stale v0 sorts first in arrival order — exactly what the old firstOrNull picked up.
|
||||
assertEquals(0L, banlistSoFar.first().version)
|
||||
assertEquals(2L, head.version)
|
||||
// The stale v1 sorts first in arrival order — exactly what the old firstOrNull picked up.
|
||||
assertEquals(1L, banlistSoFar.first().version)
|
||||
|
||||
// Now unban the troll. The head must be found regardless of arrival order — in
|
||||
// particular in the natural order, where the stale v0 comes first and is exactly
|
||||
// particular in the natural order, where the stale v1 comes first and is exactly
|
||||
// what the old firstOrNull latched onto.
|
||||
for (arrival in listOf(editions.toList(), editions.reversed())) {
|
||||
val unbanWrap = ConcordModeration.unban(owner, cp, communityId, troll.pubKey, arrival, createdAt = 4L, owner = community.ownerPubKey)
|
||||
val unban = ConcordActions.controlEditions(listOf(unbanWrap), cp).single()
|
||||
|
||||
// Chains onto the folded head (v1), not the first-arrival v0.
|
||||
assertEquals(2L, unban.version)
|
||||
// Chains onto the folded head (v2), not the first-arrival v1.
|
||||
assertEquals(3L, unban.version)
|
||||
assertEquals(head.hashHex, unban.prevHash!!.toHexKey())
|
||||
|
||||
// And the resulting state is the one the moderator asked for: troll freed, stranger still banned.
|
||||
val state = ConcordCommunityState.fold(editions + unban, community.ownerPubKey)
|
||||
val state = ConcordCommunityState.fold(editions + unban, community.communityId, community.ownerPubKey)
|
||||
assertFalse(state.authority.isBanned(troll.pubKey))
|
||||
assertTrue(state.authority.isBanned(stranger.pubKey))
|
||||
}
|
||||
}
|
||||
|
||||
/** The same stale-head trap on a versioned entity: a third role edition must be v2. */
|
||||
/** The same stale-head trap on a versioned entity: a third role edition must be v3. */
|
||||
@Test
|
||||
fun thirdRoleEditionChainsOffTheFoldedHead() =
|
||||
runTest {
|
||||
@@ -168,14 +173,14 @@ class ConcordModerationTest {
|
||||
|
||||
fun role(name: String) = RoleEntity(name = name, position = 1, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire())
|
||||
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Mod"), editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Admin"), editions, createdAt = 3L, owner = community.ownerPubKey)), cp)
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role("Mod"), editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role("Admin"), editions, createdAt = 3L, owner = community.ownerPubKey)), cp)
|
||||
|
||||
for (arrival in listOf(editions.toList(), editions.reversed())) {
|
||||
val third = ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Owner"), arrival, createdAt = 4L, owner = community.ownerPubKey)), cp).single()
|
||||
assertEquals(2L, third.version)
|
||||
val third = ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role("Owner"), arrival, createdAt = 4L, owner = community.ownerPubKey)), cp).single()
|
||||
assertEquals(3L, third.version)
|
||||
|
||||
val state = ConcordCommunityState.fold(editions + third, community.ownerPubKey)
|
||||
val state = ConcordCommunityState.fold(editions + third, community.communityId, community.ownerPubKey)
|
||||
assertEquals("Owner", state.roles[roleId.toHexKey()]?.name)
|
||||
}
|
||||
}
|
||||
@@ -195,27 +200,27 @@ class ConcordModerationTest {
|
||||
val communityId = community.communityId
|
||||
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
|
||||
|
||||
// v0: the owner bans the troll.
|
||||
// v1: the owner bans the troll.
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
|
||||
// v1: the stranger — who holds nothing — forges an empty banlist at the tip.
|
||||
// v2: the stranger — who holds nothing — forges an empty banlist at the tip.
|
||||
val rogue = ConcordActions.controlEditions(listOf(ConcordModeration.unban(stranger, cp, communityId, troll.pubKey, editions, createdAt = 3L, owner = community.ownerPubKey)), cp).single()
|
||||
assertEquals(1L, rogue.version)
|
||||
assertEquals(2L, rogue.version)
|
||||
val poisoned = editions + rogue
|
||||
|
||||
// The owner now bans the stranger. It must chain onto v0 — the head the fold
|
||||
// honors — not onto the rogue v1, so the version is 1, not 2.
|
||||
// The owner now bans the stranger. It must chain onto v1 — the head the fold
|
||||
// honors — not onto the rogue v2, so the version is 2, not 3.
|
||||
val next =
|
||||
ConcordActions
|
||||
.controlEditions(
|
||||
listOf(ConcordModeration.ban(owner, cp, communityId, stranger.pubKey, poisoned, createdAt = 4L, owner = community.ownerPubKey)),
|
||||
cp,
|
||||
).single()
|
||||
assertEquals(1L, next.version, "the rogue tip must not inflate the honest edition's version")
|
||||
assertEquals(2L, next.version, "the rogue tip must not inflate the honest edition's version")
|
||||
|
||||
// And, critically, the owner's banlist is computed from the GATED head, so it still
|
||||
// carries the troll. Reading the rogue's content instead would launder the forged
|
||||
// unban into an owner-signed edition and free the troll for good.
|
||||
val state = ConcordCommunityState.fold(poisoned + next, community.ownerPubKey)
|
||||
val state = ConcordCommunityState.fold(poisoned + next, community.communityId, community.ownerPubKey)
|
||||
assertTrue(state.authority.isBanned(troll.pubKey), "the forged unban must not free the troll")
|
||||
assertTrue(state.authority.isBanned(stranger.pubKey))
|
||||
}
|
||||
@@ -242,22 +247,22 @@ class ConcordModerationTest {
|
||||
editions +=
|
||||
ConcordActions.controlEditions(
|
||||
listOf(
|
||||
ConcordModeration.defineRole(owner, cp, staffRoleId, RoleEntity(name = "Mod", position = 2, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire()), editions, createdAt = 2L, owner = community.ownerPubKey),
|
||||
ConcordModeration.defineRole(owner, cp, community.communityId, staffRoleId, RoleEntity(name = "Mod", position = 2, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire()), editions, createdAt = 2L, owner = community.ownerPubKey),
|
||||
),
|
||||
cp,
|
||||
)
|
||||
editions +=
|
||||
ConcordActions.controlEditions(
|
||||
listOf(
|
||||
ConcordModeration.defineRole(owner, cp, kickRoleId, RoleEntity(name = "Bouncer", position = 3, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire()), editions, createdAt = 3L, owner = community.ownerPubKey),
|
||||
ConcordModeration.defineRole(owner, cp, community.communityId, kickRoleId, RoleEntity(name = "Bouncer", position = 3, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire()), editions, createdAt = 3L, owner = community.ownerPubKey),
|
||||
),
|
||||
cp,
|
||||
)
|
||||
|
||||
assertTrue(ConcordModeration.makesStaff(listOf(staffRoleIdHex), editions, community.ownerPubKey))
|
||||
assertFalse(ConcordModeration.makesStaff(listOf(kickRoleIdHex), editions, community.ownerPubKey))
|
||||
assertFalse(ConcordModeration.makesStaff(emptyList(), editions, community.ownerPubKey), "a revoke hands out nothing")
|
||||
assertFalse(ConcordModeration.makesStaff(listOf("ee".repeat(32)), editions, community.ownerPubKey), "an unresolvable role must not trigger a delivery")
|
||||
assertTrue(ConcordModeration.makesStaff(listOf(staffRoleIdHex), editions, community.communityId, community.ownerPubKey))
|
||||
assertFalse(ConcordModeration.makesStaff(listOf(kickRoleIdHex), editions, community.communityId, community.ownerPubKey))
|
||||
assertFalse(ConcordModeration.makesStaff(emptyList(), editions, community.communityId, community.ownerPubKey), "a revoke hands out nothing")
|
||||
assertFalse(ConcordModeration.makesStaff(listOf("ee".repeat(32)), editions, community.communityId, community.ownerPubKey), "an unresolvable role must not trigger a delivery")
|
||||
|
||||
suspend fun grantEntity(
|
||||
roleIds: List<String>,
|
||||
@@ -296,4 +301,96 @@ class ConcordModerationTest {
|
||||
assertNull(grantEntity(listOf("ee".repeat(32)), community.controlRoot).controlWrap, "an unresolved role conservatively delivers nothing")
|
||||
assertNull(grantEntity(listOf(staffRoleIdHex), controlRoot = null).controlWrap, "no held secret, no delivery (legacy community or keyless granter)")
|
||||
}
|
||||
|
||||
/**
|
||||
* CORD-04 §1/§5: every non-owner authority action cites the Grant it acts under (`vac`), and a
|
||||
* reader — this client and the reference one alike — drops a non-owner edition that doesn't. We
|
||||
* never wrote it, so every edition a delegated admin authored here was invisible to Armada.
|
||||
*/
|
||||
@Test
|
||||
fun aDelegatedAdminsEditionsCiteTheirGrantAndAreHonored() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
|
||||
val cp = community.controlPlane
|
||||
val communityId = community.communityId
|
||||
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
|
||||
|
||||
fun add(wrap: Event) = ConcordActions.controlEditions(listOf(wrap), cp).single().also { editions += it }
|
||||
|
||||
val roleId = ByteArray(32) { 0x31 }
|
||||
val adminBits = ConcordPermissions.of(ConcordPermissions.BAN, ConcordPermissions.MANAGE_METADATA, ConcordPermissions.MANAGE_ROLES)
|
||||
val role = add(ConcordModeration.defineRole(owner, cp, communityId, roleId, RoleEntity(name = "Admin", position = 1, permissions = adminBits.toWire()), editions, 2L, owner = community.ownerPubKey))
|
||||
val grant = add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, listOf(roleId.toHexKey()), editions, 3L, owner = community.ownerPubKey))
|
||||
assertNull(role.authorityCitation, "the owner cites nothing")
|
||||
assertNull(grant.authorityCitation)
|
||||
|
||||
val ban = add(ConcordModeration.ban(admin, cp, communityId, troll.pubKey, editions, 4L, owner = community.ownerPubKey))
|
||||
val vac = ban.authorityCitation
|
||||
assertNotNull(vac, "a delegated admin's edition must cite its Grant")
|
||||
assertEquals(ConcordKeyDerivation.grantCoordinate(communityId, admin.pubKey.hexToByteArray()).toHexKey(), vac.grantId.toHexKey())
|
||||
assertEquals(grant.version, vac.grantVersion)
|
||||
assertEquals(grant.hashHex, vac.grantHash.toHexKey())
|
||||
|
||||
val rename = add(ConcordModeration.editMetadata(admin, cp, communityId, MetadataEntity(name = "Renamed"), editions, 5L, owner = community.ownerPubKey))
|
||||
assertNotNull(rename.authorityCitation)
|
||||
|
||||
val state = ConcordCommunityState.fold(editions, communityId, community.ownerPubKey)
|
||||
assertTrue(state.authority.isBanned(troll.pubKey), "the cited ban is honored")
|
||||
assertEquals("Renamed", state.metadata?.name, "and so is the cited metadata edit")
|
||||
|
||||
// The same actions stripped of their citation are dropped.
|
||||
val uncited = editions.map { if (it.author == admin.pubKey) it.withCitation(null) else it }
|
||||
val stripped = ConcordCommunityState.fold(uncited, communityId, community.ownerPubKey)
|
||||
assertFalse(stripped.authority.isBanned(troll.pubKey))
|
||||
assertEquals("Nostrichs", stripped.metadata?.name)
|
||||
}
|
||||
|
||||
/** CORD-04 §1/§2: a new entity starts at version 1, and a Role carries its own id as `role_id`. */
|
||||
@Test
|
||||
fun newEntitiesStartAtVersionOneAndRolesCarryTheirRoleId() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
|
||||
val cp = community.controlPlane
|
||||
val editions = ConcordActions.controlEditions(community.genesisWraps, cp)
|
||||
val roleId = ByteArray(32) { 0x41 }
|
||||
|
||||
val role =
|
||||
ConcordActions
|
||||
.controlEditions(
|
||||
listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, RoleEntity(name = "Mod", position = 2), editions, 2L, owner = community.ownerPubKey)),
|
||||
cp,
|
||||
).single()
|
||||
assertEquals(1L, role.version)
|
||||
assertNull(role.prevHash)
|
||||
assertEquals(roleId.toHexKey(), ConcordJson.decodeOrNull<RoleEntity>(role.content)?.roleId)
|
||||
|
||||
val ban = ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, community.communityId, troll.pubKey, editions, 3L, owner = community.ownerPubKey)), cp).single()
|
||||
assertEquals(1L, ban.version)
|
||||
}
|
||||
|
||||
/** CORD-04 §2 / CORD-02 §6: a write every reader would drop is refused instead of published. */
|
||||
@Test
|
||||
fun writesPastTheProtocolCapsAreRefused() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
|
||||
val cp = community.controlPlane
|
||||
val cid = community.communityId
|
||||
val editions = ConcordActions.controlEditions(community.genesisWraps, cp)
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ConcordModeration.defineRole(owner, cp, cid, ByteArray(32) { 1 }, RoleEntity(name = "r".repeat(65), position = 2), editions, 2L, owner = community.ownerPubKey)
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ConcordModeration.defineRole(owner, cp, cid, ByteArray(32) { 1 }, RoleEntity(name = "Peer", position = 0), editions, 2L, owner = community.ownerPubKey)
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ConcordModeration.editMetadata(owner, cp, cid, MetadataEntity(name = "n".repeat(65)), editions, 2L, owner = community.ownerPubKey)
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ConcordModeration.editMetadata(owner, cp, cid, MetadataEntity(name = "ok", description = "d".repeat(10_001)), editions, 2L, owner = community.ownerPubKey)
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ConcordModeration.grant(owner, cp, cid, admin.pubKey, (1..65).map { it.toString(16).padStart(64, '0') }, editions, 2L, owner = community.ownerPubKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
|
||||
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.cord02Community.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The receive side of CORD-06 rotations as commons wires it: the `vac`-cited authority check a
|
||||
* receiver runs before adopting (I9), racing rotations converging on the lowest authorized root
|
||||
* and the down-only same-epoch heal (I12), and the sibling address a session watches for it.
|
||||
*/
|
||||
class ConcordRotationReceiveTest {
|
||||
private val owner = NostrSignerInternal(KeyPair())
|
||||
private val admin = NostrSignerInternal(KeyPair())
|
||||
private val member = NostrSignerInternal(KeyPair())
|
||||
private val now = 1_700_000_000L
|
||||
|
||||
private class Fixture(
|
||||
val community: NewConcordCommunity,
|
||||
val editions: List<ControlEdition>,
|
||||
)
|
||||
|
||||
/** A community whose owner granted [admin] a BAN role. */
|
||||
private suspend fun withAdmin(): Fixture {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val cp = community.controlPlane
|
||||
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
|
||||
val roleId = ByteArray(32) { (it + 1).toByte() }
|
||||
val role = RoleEntity(name = "Admin", position = 1, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire())
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role, editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.grant(owner, cp, community.communityId, admin.pubKey, listOf(roleId.toHexKey()), editions, createdAt = 3L, owner = community.ownerPubKey)), cp)
|
||||
return Fixture(community, editions)
|
||||
}
|
||||
|
||||
private fun entryFor(
|
||||
community: NewConcordCommunity,
|
||||
root: String = community.communityRoot.toHexKey(),
|
||||
epoch: Long = community.rootEpoch,
|
||||
heldRoots: List<HeldRoot> = emptyList(),
|
||||
) = ConcordCommunityListEntry(
|
||||
id = community.communityIdHex,
|
||||
owner = community.ownerPubKey,
|
||||
ownerSalt = community.ownerSalt.toHexKey(),
|
||||
root = root,
|
||||
rootEpoch = epoch,
|
||||
controlPk = community.controlPkHex,
|
||||
heldRoots = heldRoots,
|
||||
relays = listOf("wss://r.example"),
|
||||
name = "Nostrichs",
|
||||
)
|
||||
|
||||
private suspend fun rotate(
|
||||
community: NewConcordCommunity,
|
||||
rotator: NostrSigner,
|
||||
newRoot: ByteArray,
|
||||
authority: AuthorityCitation?,
|
||||
): List<Event> {
|
||||
val newEpoch = community.rootEpoch + 1
|
||||
val controlRoot = ByteArray(32) { 0x6B }
|
||||
return ConcordRefounding.buildBaseRekeyWraps(
|
||||
rotatorSigner = rotator,
|
||||
baseRekeyKey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch),
|
||||
recipientsXOnly = listOf(member.pubKey),
|
||||
staffXOnly = emptySet(),
|
||||
newRoot = newRoot,
|
||||
newControlPk = ConcordKeyDerivation.controlSignerKey(controlRoot, community.communityId, newEpoch).publicKey,
|
||||
newControlRoot = controlRoot,
|
||||
newEpoch = newEpoch,
|
||||
prevEpoch = community.rootEpoch,
|
||||
prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey(),
|
||||
createdAt = now,
|
||||
authority = authority,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun receive(
|
||||
f: Fixture,
|
||||
wraps: List<Event>,
|
||||
): ReceivedRefounding? {
|
||||
val entry = entryFor(f.community)
|
||||
val authority = ConcordCommunityState.fold(f.editions, f.community.communityId, f.community.ownerPubKey).authority
|
||||
return ConcordActions.openBaseRekey(
|
||||
wraps = wraps,
|
||||
baseRekey = ConcordActions.nextBaseRekeyPlane(f.community.communityRoot, f.community.communityId, f.community.rootEpoch),
|
||||
recipientSigner = member,
|
||||
communityId = f.community.communityIdHex,
|
||||
priorRoot = f.community.communityRoot,
|
||||
rootEpoch = f.community.rootEpoch,
|
||||
accept = { ConcordReceive.isHonoredRotation(entry, f.editions, authority, it) },
|
||||
)
|
||||
}
|
||||
|
||||
// ---- I9 ----------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun anAdminsRotationIsHonoredOnlyWithItsGrantCited() =
|
||||
runTest {
|
||||
val f = withAdmin()
|
||||
val entry = entryFor(f.community)
|
||||
val citation = ConcordReceive.rotationCitation(entry, f.editions, admin.pubKey)
|
||||
assertNotNull(citation, "a BAN-holding admin has a Grant to cite")
|
||||
assertNull(ConcordReceive.rotationCitation(entry, f.editions, owner.pubKey), "the owner cites nothing")
|
||||
|
||||
val root = ByteArray(32) { 0x22 }
|
||||
assertContentEquals(root, receive(f, rotate(f.community, admin, root, citation))?.newRoot)
|
||||
assertNull(receive(f, rotate(f.community, admin, root, authority = null)), "an uncited delegated rotation is dropped")
|
||||
assertContentEquals(root, receive(f, rotate(f.community, owner, root, authority = null))?.newRoot, "the owner needs no citation")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRotationCitingAGrantWeHaveNotSyncedIsParked() =
|
||||
runTest {
|
||||
val f = withAdmin()
|
||||
val real = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)!!
|
||||
val ahead = AuthorityCitation(real.grantId, real.grantVersion + 1, real.grantHash)
|
||||
assertNull(receive(f, rotate(f.community, admin, ByteArray(32) { 0x22 }, ahead)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStrangerCannotRotateEvenCitingSomeonesGrant() =
|
||||
runTest {
|
||||
val f = withAdmin()
|
||||
val stranger = NostrSignerInternal(KeyPair())
|
||||
val adminsCitation = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)
|
||||
assertNull(receive(f, rotate(f.community, stranger, ByteArray(32) { 0x22 }, adminsCitation)))
|
||||
}
|
||||
|
||||
// ---- I12 ---------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun racingHonoredRotationsConvergeOnTheLowestRoot() =
|
||||
runTest {
|
||||
val f = withAdmin()
|
||||
val citation = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)
|
||||
val high = ByteArray(32) { 0x70 }
|
||||
val low = ByteArray(32) { 0x05 }
|
||||
val wraps = rotate(f.community, owner, high, null) + rotate(f.community, admin, low, citation)
|
||||
assertContentEquals(low, receive(f, wraps)?.newRoot)
|
||||
assertContentEquals(low, receive(f, wraps.reversed())?.newRoot)
|
||||
|
||||
// An uncited (dishonored) lower root never wins the race.
|
||||
val rogue = rotate(f.community, admin, ByteArray(32) { 0x01 }, null)
|
||||
assertContentEquals(high, receive(f, rotate(f.community, owner, high, null) + rogue)?.newRoot)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theHealMovesOnlyToAStrictlyLowerSiblingAndKeepsTheLoser() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
|
||||
val prior = HeldRoot(0, "aa".repeat(32), community.controlPkHex)
|
||||
val adopted = entryFor(community, root = "70".repeat(32), epoch = 1, heldRoots = listOf(prior))
|
||||
|
||||
fun sibling(root: String) = ReceivedRefounding(root.hexToByteArray(), 1, owner.pubKey, ByteArray(32) { 3 }, null)
|
||||
|
||||
val healed = ConcordReceive.withHealedRoot(adopted, sibling("05".repeat(32)))
|
||||
assertNotNull(healed)
|
||||
assertEquals("05".repeat(32), healed.root)
|
||||
assertEquals(1L, healed.rootEpoch)
|
||||
assertEquals(ByteArray(32) { 3 }.toHexKey(), healed.controlPk, "the control pair is the winner's")
|
||||
assertTrue(healed.heldRoots.any { it.epoch == 1L && it.key == "70".repeat(32) && it.controlPk == null }, "the losing fork's root is kept for its messages")
|
||||
assertEquals(prior.key, ConcordRefounding.canonicalHeldRoots(healed.heldRoots).first { it.epoch == 0L }.key)
|
||||
|
||||
assertNull(ConcordReceive.withHealedRoot(adopted, sibling("90".repeat(32))), "down-only: a higher sibling never re-forks the epoch")
|
||||
assertNull(ConcordReceive.withHealedRoot(adopted, sibling("70".repeat(32))))
|
||||
|
||||
// The next adoption keeps both same-epoch roots instead of collapsing them by epoch.
|
||||
val next = ConcordReceive.withAdoptedRoot(healed, ByteArray(32) { 9 }, 2)
|
||||
assertEquals(
|
||||
setOf("05".repeat(32), "70".repeat(32)),
|
||||
next.heldRoots
|
||||
.filter { it.epoch == 1L }
|
||||
.map { it.key }
|
||||
.toSet(),
|
||||
)
|
||||
assertEquals("05".repeat(32), ConcordRefounding.canonicalHeldRoots(next.heldRoots).first { it.epoch == 1L }.key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSessionWatchesTheRotationIntoItsOwnEpoch() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
|
||||
val prior = HeldRoot(community.rootEpoch, community.communityRoot.toHexKey(), community.controlPkHex)
|
||||
val entry = entryFor(community, root = "70".repeat(32), epoch = community.rootEpoch + 1, heldRoots = listOf(prior))
|
||||
|
||||
val sibling = ConcordActions.siblingBaseRekeyPlane(entry)
|
||||
assertNotNull(sibling)
|
||||
assertEquals(ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex, sibling.publicKeyHex)
|
||||
assertNull(ConcordActions.siblingBaseRekeyPlane(entryFor(community)), "a joiner holding no prior root has nothing to watch")
|
||||
|
||||
val session = ConcordCommunitySession(entry, member.pubKey)
|
||||
assertEquals(sibling.publicKeyHex, session.siblingBaseRekeyAddress)
|
||||
assertTrue(session.ownsPlane(sibling.publicKeyHex))
|
||||
assertTrue(session.auxStreamKeys().any { it.publicKeyHex == sibling.publicKeyHex })
|
||||
|
||||
val wraps = rotate(community, owner, ByteArray(32) { 0x05 }, null)
|
||||
wraps.forEach { session.ingest(it) }
|
||||
assertEquals(wraps.map { it.id }.toSet(), session.pendingSiblingRekeyWraps().map { it.id }.toSet())
|
||||
|
||||
assertTrue(ConcordSubscriptionPlanner.auxiliaryPlaneSubs(listOf(entry)).any { it.pubKeyHex == sibling.publicKeyHex })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLosingForkRootIsNotFoldedAsAControlPlane() {
|
||||
val community = "11".repeat(32)
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
id = community,
|
||||
owner = "22".repeat(32),
|
||||
ownerSalt = "33".repeat(32),
|
||||
root = "44".repeat(32),
|
||||
rootEpoch = 2,
|
||||
heldRoots = listOf(HeldRoot(1, "05".repeat(32)), HeldRoot(1, "70".repeat(32))),
|
||||
relays = listOf("wss://r.example"),
|
||||
)
|
||||
// One Control Plane for epoch 1 (the winner's), plus the current one.
|
||||
assertEquals(2, ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)).size)
|
||||
assertFalse(ConcordRefounding.canonicalHeldRoots(entry.heldRoots).any { it.key == "70".repeat(32) })
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -58,7 +58,7 @@ class ConcordSubscriptionPlannerTest {
|
||||
relays = listOf("wss://r.example"),
|
||||
name = "Nostrichs",
|
||||
)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
|
||||
val subs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, state)
|
||||
|
||||
// Both the current-epoch and the prior-epoch #general planes are subscribed.
|
||||
@@ -93,7 +93,7 @@ class ConcordSubscriptionPlannerTest {
|
||||
assertTrue(controlSubs[0].channelId == null)
|
||||
|
||||
// Channel-plane subs cover the folded #general channel.
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
|
||||
val channelSubs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, state)
|
||||
val general = channelSubs.firstOrNull { it.channelId?.channelId == community.generalChannelIdHex }
|
||||
assertTrue(general != null)
|
||||
@@ -127,7 +127,7 @@ class ConcordSubscriptionPlannerTest {
|
||||
relays = listOf("wss://r.example"),
|
||||
name = "Nostrichs",
|
||||
)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
|
||||
|
||||
// Never read (lastRead == 0) ⇒ the newest few wraps (previewLimit), no `since`.
|
||||
val previews = ConcordSubscriptionPlanner.channelPreviewFilters(entry, state, lastReadFor = { 0L }, previewLimit = 10)
|
||||
@@ -158,7 +158,7 @@ class ConcordSubscriptionPlannerTest {
|
||||
relays = listOf("wss://r.example"),
|
||||
name = "Nostrichs",
|
||||
)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
|
||||
|
||||
// Read before (lastRead > 0) ⇒ everything since, capped, so the unread badge is accurate.
|
||||
val lastRead = 1_700_000_000L
|
||||
@@ -225,7 +225,7 @@ class ConcordSubscriptionPlannerTest {
|
||||
relays = listOf("wss://r.example"),
|
||||
name = "Nostrichs",
|
||||
)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
|
||||
|
||||
val controlPk = community.controlPlane.address
|
||||
val guestbookPk = ConcordActions.guestbookPlane(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex
|
||||
|
||||
+5
-8
@@ -46,14 +46,11 @@ class ConcordChannelDissolvedTest {
|
||||
author: String = owner,
|
||||
) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0)
|
||||
|
||||
private fun state(dissolved: Boolean): ConcordCommunityState {
|
||||
val editions =
|
||||
buildList {
|
||||
add(ed(ControlEntityKind.CHANNEL, channelId, """{"name":"general"}"""))
|
||||
if (dissolved) add(ed(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""))
|
||||
}
|
||||
return ConcordCommunityState.fold(editions, owner)
|
||||
}
|
||||
// The tombstone lives on its own plane (CORD-02 §9); the session sets the flag from there.
|
||||
private fun state(dissolved: Boolean): ConcordCommunityState =
|
||||
ConcordCommunityState
|
||||
.fold(listOf(ed(ControlEntityKind.CHANNEL, channelId, """{"name":"general"}""")), "cc".repeat(32).hexToByteArray(), owner)
|
||||
.withDissolved(dissolved)
|
||||
|
||||
@Test
|
||||
fun liveCommunityLetsTheOwnerPost() {
|
||||
|
||||
+105
-33
@@ -26,8 +26,13 @@ import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragments
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListIncompleteException
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@@ -40,14 +45,15 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The "leave a Concord community" path: `unfollow` read-modify-writes the private kind-13302 list.
|
||||
* Everything that makes leaving safe lives here — it must drop only the named community, keep the
|
||||
* other memberships (and their secrets) intact, and be a pure local list edit that never depends on
|
||||
* the community's own (possibly dead) relays.
|
||||
* The "leave a Concord community" path: `unfollow` read-modify-writes the Community List (CORD-02 §8).
|
||||
* Everything that makes leaving safe lives here — it must remove only the named community, keep the
|
||||
* other memberships (and their secrets) intact, leave a tombstone behind (only a tombstone subtracts
|
||||
* a membership), and be a pure local list edit that never depends on the community's own (possibly
|
||||
* dead) relays.
|
||||
*/
|
||||
class ConcordChannelListLeaveTest {
|
||||
private val signer = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray()))
|
||||
@@ -72,12 +78,19 @@ class ConcordChannelListLeaveTest {
|
||||
/** Serves the list only from the offline backup — the state a dead-relay community lands in. */
|
||||
private class BackupOnlyRepository(
|
||||
var saved: ConcordCommunityListEvent?,
|
||||
var fragments: List<ConcordCommunityListFragmentEvent> = emptyList(),
|
||||
) : ConcordListRepository {
|
||||
override fun concordList() = saved
|
||||
|
||||
override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) {
|
||||
saved = newConcordList
|
||||
}
|
||||
|
||||
override fun concordListFragments() = fragments
|
||||
|
||||
override fun updateConcordListFragmentTo(fragment: ConcordCommunityListFragmentEvent) {
|
||||
fragments = fragments.filterNot { it.index() == fragment.index() } + fragment
|
||||
}
|
||||
}
|
||||
|
||||
private class StubCache : ICacheProvider {
|
||||
@@ -106,64 +119,123 @@ class ConcordChannelListLeaveTest {
|
||||
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
|
||||
}
|
||||
|
||||
private suspend fun state(vararg entries: ConcordCommunityListEntry) =
|
||||
ConcordChannelListState(
|
||||
signer = signer,
|
||||
cache = StubCache(),
|
||||
scope = CoroutineScope(Dispatchers.Unconfined),
|
||||
// The cached note is empty (nothing folded from relays), so every read falls back to the
|
||||
// offline backup — exactly the situation for a community whose relays no longer answer.
|
||||
settings = BackupOnlyRepository(ConcordCommunityListEvent.create(signer, entries.toList())),
|
||||
)
|
||||
/** The cached notes are empty (nothing folded from relays), so every read falls back to the offline backup. */
|
||||
private suspend fun state(vararg entries: ConcordCommunityListEntry): Pair<ConcordChannelListState, BackupOnlyRepository> {
|
||||
val repo = BackupOnlyRepository(null)
|
||||
val list = ConcordChannelListState(signer = signer, cache = StubCache(), scope = CoroutineScope(Dispatchers.Unconfined), settings = repo)
|
||||
list.markRelaysConfirmed()
|
||||
for (e in entries) list.follow(e)
|
||||
return list to repo
|
||||
}
|
||||
|
||||
private suspend fun readBack(fragments: List<Event>) = ConcordListFragmentSet.resolve(fragments.map { it as ConcordCommunityListFragmentEvent }, signer)
|
||||
|
||||
@Test
|
||||
fun leavingDropsOnlyThatCommunity() =
|
||||
fun leavingDropsOnlyThatCommunityAndTombstonesIt() =
|
||||
runTest {
|
||||
val list = state(entry(alpha, "Alpha"), entry(beta, "Beta"))
|
||||
val (list, repo) = state(entry(alpha, "Alpha"), entry(beta, "Beta"))
|
||||
|
||||
val left = list.unfollow(alpha)!!
|
||||
val remaining = left.decrypt(signer)
|
||||
val left = list.unfollow(alpha)
|
||||
assertEquals(1, left.size)
|
||||
|
||||
assertEquals(1, remaining.size)
|
||||
assertEquals(beta, remaining[0].id)
|
||||
val remaining = list.entries()
|
||||
assertEquals(listOf(beta), remaining.map { it.id })
|
||||
// The surviving membership keeps its secrets — leaving one community must not damage another.
|
||||
assertEquals("2".repeat(64), remaining[0].root)
|
||||
assertEquals(3L, remaining[0].rootEpoch)
|
||||
|
||||
val doc = readBack(repo.fragments).doc
|
||||
val tombstoned = ConcordListFragments.removals(doc)
|
||||
assertTrue(alpha in tombstoned, "a leave must leave a tombstone, or another fragment can re-add it")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavingTheLastCommunityEmptiesTheList() =
|
||||
runTest {
|
||||
val list = state(entry(alpha, "Alpha"))
|
||||
|
||||
val left = list.unfollow(alpha)!!
|
||||
|
||||
assertTrue(left.decrypt(signer).isEmpty())
|
||||
val (list, _) = state(entry(alpha, "Alpha"))
|
||||
list.unfollow(alpha)
|
||||
assertTrue(list.entries().isEmpty())
|
||||
}
|
||||
|
||||
/** Nothing to publish when we weren't a member: the caller's publish is a no-op on null. */
|
||||
/** Nothing to publish when we weren't a member. */
|
||||
@Test
|
||||
fun leavingSomethingWeNeverJoinedIsANoOp() =
|
||||
runTest {
|
||||
val list = state(entry(alpha, "Alpha"))
|
||||
val (list, _) = state(entry(alpha, "Alpha"))
|
||||
assertTrue(list.unfollow(beta).isEmpty())
|
||||
}
|
||||
|
||||
assertNull(list.unfollow(beta))
|
||||
@Test
|
||||
fun reJoiningAfterALeaveResurrectsTheMembership() =
|
||||
runTest {
|
||||
val (list, _) = state(entry(alpha, "Alpha"))
|
||||
list.unfollow(alpha)
|
||||
val rejoin =
|
||||
ConcordCommunityListEntry(
|
||||
id = alpha,
|
||||
owner = signer.pubKey,
|
||||
ownerSalt = "1".repeat(64),
|
||||
root = "2".repeat(64),
|
||||
rootEpoch = 3,
|
||||
name = "Alpha",
|
||||
addedAt = Long.MAX_VALUE / 2,
|
||||
)
|
||||
list.follow(rejoin)
|
||||
assertEquals(listOf(alpha), list.entries().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reJoiningInTheSameMillisecondStillOutranksTheLeave() =
|
||||
runTest {
|
||||
val (list, _) = state(entry(alpha, "Alpha"))
|
||||
list.unfollow(alpha)
|
||||
// A stale entry (added long before the leave) re-followed: it must come back live.
|
||||
list.follow(entry(alpha, "Alpha"))
|
||||
assertEquals(listOf(alpha), list.entries().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnloadedListRefusesToWrite() =
|
||||
runTest {
|
||||
// Nothing held and the relays not asked yet: writing would replace fragments we never saw.
|
||||
val list = ConcordChannelListState(signer = signer, cache = StubCache(), scope = CoroutineScope(Dispatchers.Unconfined), settings = BackupOnlyRepository(null))
|
||||
assertFailsWith<ConcordListIncompleteException> { list.follow(entry(alpha, "Alpha")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMembershipOnlyTheRetiredListCarriesIsMigratedByTheNextWrite() =
|
||||
runTest {
|
||||
val repo = BackupOnlyRepository(ConcordCommunityListEvent.create(signer, listOf(entry(alpha, "Alpha"))))
|
||||
val list = ConcordChannelListState(signer = signer, cache = StubCache(), scope = CoroutineScope(Dispatchers.Unconfined), settings = repo)
|
||||
list.markRelaysConfirmed()
|
||||
assertEquals(listOf(alpha), list.entries().map { it.id })
|
||||
|
||||
list.follow(entry(beta, "Beta"))
|
||||
val migrated =
|
||||
ConcordCommunityList
|
||||
.decodeDocument(readBack(repo.fragments).doc)
|
||||
.entries
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
assertEquals(setOf(alpha, beta), migrated)
|
||||
}
|
||||
|
||||
/**
|
||||
* The list is only readable by its owner, so the leave write must stay self-encrypted — a leave
|
||||
* The list is only readable by its owner, so every fragment must stay self-encrypted — a leave
|
||||
* that leaked the remaining memberships in cleartext would be worse than not leaving at all.
|
||||
*/
|
||||
@Test
|
||||
fun theRewrittenListStaysSelfEncrypted() =
|
||||
runTest {
|
||||
val list = state(entry(alpha, "Alpha"), entry(beta, "Beta"))
|
||||
val (list, _) = state(entry(alpha, "Alpha"), entry(beta, "Beta"))
|
||||
|
||||
val left = list.unfollow(alpha)!!
|
||||
val left = list.unfollow(alpha).single() as ConcordCommunityListFragmentEvent
|
||||
|
||||
assertEquals(ConcordCommunityListEvent.KIND, left.kind)
|
||||
assertEquals(ConcordCommunityListFragmentEvent.KIND, left.kind)
|
||||
assertEquals("0", left.dTag())
|
||||
assertTrue(beta !in left.content)
|
||||
assertTrue(ConcordListFragments.hexToB64(beta) !in left.content)
|
||||
val stranger = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000009".hexToByteArray()))
|
||||
assertTrue(left.decrypt(stranger).isEmpty())
|
||||
assertEquals(null, left.decryptPlaintext(stranger))
|
||||
}
|
||||
}
|
||||
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* 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.amethyst.commons.actions.ConcordSubscriptionPlanner
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The Chat Plane batch at the commons layer: Private Channels on their own keys (S2), in-stream
|
||||
* deletes (S3), the Chat ingest gate (S9) and the channel-name build cap (I14).
|
||||
*/
|
||||
class ConcordChatPlaneConformanceTest {
|
||||
private val owner = NostrSignerInternal(KeyPair())
|
||||
private val secretId = ByteArray(32) { 0x5C }
|
||||
private val secretIdHex = secretId.toHexKey()
|
||||
private val channelKey = ByteArray(32) { 0x3C }
|
||||
|
||||
private fun entryFor(
|
||||
community: NewConcordCommunity,
|
||||
privateChannels: List<PrivateChannelKey> = emptyList(),
|
||||
heldRoots: List<HeldRoot> = emptyList(),
|
||||
) = ConcordCommunityListEntry(
|
||||
id = community.communityIdHex,
|
||||
owner = community.ownerPubKey,
|
||||
ownerSalt = community.ownerSalt.toHexKey(),
|
||||
root = community.communityRoot.toHexKey(),
|
||||
rootEpoch = community.rootEpoch,
|
||||
controlPk = community.controlPkHex,
|
||||
controlRoot = community.controlRoot.toHexKey(),
|
||||
heldRoots = heldRoots,
|
||||
privateChannels = privateChannels,
|
||||
relays = listOf("wss://r.example"),
|
||||
name = "Nostrichs",
|
||||
)
|
||||
|
||||
/** Genesis plus a `private:true` channel edition, as the Control Plane delivers them. */
|
||||
private suspend fun communityWithPrivateChannel(): Pair<NewConcordCommunity, List<Event>> {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val define =
|
||||
ConcordModeration.defineChannel(
|
||||
owner,
|
||||
community.controlPlane,
|
||||
community.communityId,
|
||||
secretId,
|
||||
ChannelEntity(name = "secret", private = true),
|
||||
community.genesisEditions,
|
||||
createdAt = 2L,
|
||||
owner = community.ownerPubKey,
|
||||
)
|
||||
return community to (community.genesisWraps + define)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPrivateChannelWithoutAHeldKeyIsNeverDerivedOnTheRootPlane() =
|
||||
runTest {
|
||||
val (community, control) = communityWithPrivateChannel()
|
||||
val captured = mutableListOf<Event>()
|
||||
val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor }
|
||||
control.forEach { session.ingest(it) }
|
||||
val state = session.state.value!!
|
||||
assertTrue(state.channels[secretIdHex]!!.definition.private)
|
||||
|
||||
val rootPlane = ConcordActions.publicChannel(community.communityRoot, secretId, community.rootEpoch)
|
||||
assertFalse(rootPlane.publicKeyHex in session.channelAddresses(), "a private channel must never be subscribed on the root plane")
|
||||
assertTrue(session.streamKeys().none { it.publicKeyHex == rootPlane.publicKeyHex })
|
||||
assertNull(session.currentChannelPlane(secretIdHex), "no plane to write without the key")
|
||||
assertNull(ConcordActions.currentChannelPlane(session.entry, state, secretIdHex))
|
||||
assertFalse(ConcordActions.canAccessChannel(session.entry, state, secretIdHex))
|
||||
|
||||
// The planner and the plane registry agree: nothing for the keyless private channel.
|
||||
val subs = ConcordSubscriptionPlanner.channelPlaneSubs(session.entry, state)
|
||||
assertTrue(subs.none { it.channelId?.channelId == secretIdHex })
|
||||
val registry = ConcordPlaneRegistry().apply { registerChannels(session.entry, state) }
|
||||
assertFalse(registry.isKnownPlane(rootPlane.publicKeyHex))
|
||||
|
||||
// A post someone made on the root-derived plane never reaches the store.
|
||||
val leaked = ConcordActions.buildChannelMessage(owner, rootPlane, secretIdHex, community.rootEpoch, "psst", 3L)
|
||||
assertEquals(ConcordIngestOutcome.NOT_MINE, session.ingest(leaked))
|
||||
assertTrue(captured.none { it.content == "psst" })
|
||||
|
||||
// The channel object is locked: no composer, no post.
|
||||
val channel = ConcordChannel(ConcordChannelId(community.communityIdHex, secretIdHex))
|
||||
channel.updateFrom(state, emptySet(), owner.pubKey, keyHeld = false)
|
||||
assertFalse(channel.keyHeld)
|
||||
assertFalse(channel.canPost())
|
||||
|
||||
// The public #general is untouched.
|
||||
assertNotNull(session.currentChannelPlane(community.generalChannelIdHex))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aHeldPrivateKeyReadsAndWritesOnItsOwnPlaneAtTheChannelEpoch() =
|
||||
runTest {
|
||||
val (community, control) = communityWithPrivateChannel()
|
||||
val channelEpoch = 3L
|
||||
val entry = entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), channelEpoch, "secret")))
|
||||
val captured = mutableListOf<Event>()
|
||||
val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> captured += rumor }
|
||||
control.forEach { session.ingest(it) }
|
||||
|
||||
val privatePlane = ConcordChannelKeys.privateChannel(channelKey, secretId, channelEpoch)
|
||||
val plane = session.currentChannelPlane(secretIdHex)!!
|
||||
assertEquals(privatePlane.publicKeyHex, plane.key.publicKeyHex)
|
||||
assertEquals(channelEpoch, plane.epoch, "a private channel binds to its own epoch, not the root epoch")
|
||||
assertTrue(privatePlane.publicKeyHex in session.channelAddresses())
|
||||
assertTrue(session.streamKeys().any { it.publicKeyHex == privatePlane.publicKeyHex })
|
||||
val rootPlane = ConcordActions.publicChannel(community.communityRoot, secretId, community.rootEpoch)
|
||||
assertFalse(rootPlane.publicKeyHex in session.channelAddresses())
|
||||
|
||||
val subs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, session.state.value!!)
|
||||
assertEquals(listOf(privatePlane.publicKeyHex), subs.filter { it.channelId?.channelId == secretIdHex }.map { it.pubKeyHex })
|
||||
|
||||
val msg = ConcordActions.buildChannelMessage(owner, privatePlane, secretIdHex, channelEpoch, "members only", 4L)
|
||||
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(msg))
|
||||
assertEquals(1, captured.count { it.content == "members only" })
|
||||
|
||||
// Bound to the ROOT epoch on the private plane: a binding mismatch, dropped.
|
||||
val wrongEpoch = ConcordActions.buildChannelMessage(owner, privatePlane, secretIdHex, community.rootEpoch, "wrong epoch", 5L)
|
||||
session.ingest(wrongEpoch)
|
||||
assertTrue(captured.none { it.content == "wrong epoch" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aKeyDeliveredLaterIsAdoptedInPlace() =
|
||||
runTest {
|
||||
val (community, control) = communityWithPrivateChannel()
|
||||
val session = ConcordCommunitySession(entryFor(community), owner.pubKey)
|
||||
control.forEach { session.ingest(it) }
|
||||
assertNull(session.currentChannelPlane(secretIdHex))
|
||||
|
||||
val withKey = entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), 1L)))
|
||||
assertTrue(session.adoptPrivateChannels(withKey))
|
||||
assertEquals(ConcordChannelKeys.privateChannel(channelKey, secretId, 1L).publicKeyHex, session.currentChannelPlane(secretIdHex)?.key?.publicKeyHex)
|
||||
assertFalse(session.adoptPrivateChannels(withKey), "adopting the same keys again is a no-op")
|
||||
// The buffered Control Plane survived: still folded.
|
||||
assertEquals(
|
||||
"Nostrichs",
|
||||
session.state.value
|
||||
?.metadata
|
||||
?.name,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aKeyArrivingWithControlMaterialIsStillAdoptedThroughTheRegistry() =
|
||||
runTest {
|
||||
val (community, control) = communityWithPrivateChannel()
|
||||
// A plain member: holds the control_pk but not the control_root.
|
||||
val member =
|
||||
ConcordCommunityListEntry(
|
||||
id = community.communityIdHex,
|
||||
owner = community.ownerPubKey,
|
||||
ownerSalt = community.ownerSalt.toHexKey(),
|
||||
root = community.communityRoot.toHexKey(),
|
||||
rootEpoch = community.rootEpoch,
|
||||
controlPk = community.controlPkHex,
|
||||
relays = listOf("wss://r.example"),
|
||||
)
|
||||
val registry = ConcordSessionRegistry()
|
||||
registry.sync(listOf(member), owner.pubKey)
|
||||
val session = registry.sessionFor(community.communityIdHex)!!
|
||||
control.forEach { session.ingest(it) }
|
||||
assertNull(session.currentChannelPlane(secretIdHex))
|
||||
|
||||
// One list update delivers both the staff write key and the private channel key: the
|
||||
// Control adoption swaps the entry first, and the channel planes must still follow.
|
||||
registry.sync(listOf(entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), 2L)))), owner.pubKey)
|
||||
assertEquals(session, registry.sessionFor(community.communityIdHex), "adopted in place, not rebuilt")
|
||||
assertEquals(ConcordChannelKeys.privateChannel(channelKey, secretId, 2L).publicKeyHex, session.currentChannelPlane(secretIdHex)?.key?.publicKeyHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeleteRidesTheChannelPlaneAndLandsAsAChannelBoundKind5() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val captured = mutableListOf<Event>()
|
||||
val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor }
|
||||
community.genesisWraps.forEach { session.ingest(it) }
|
||||
val plane = session.currentChannelPlane(community.generalChannelIdHex)!!
|
||||
|
||||
session.ingest(ConcordActions.buildChannelMessage(owner, plane.key, plane.channelIdHex, plane.epoch, "oops", 2L))
|
||||
val message = captured.single { it.content == "oops" }
|
||||
|
||||
val delete = ConcordActions.buildChannelDelete(owner, plane.key, plane.channelIdHex, plane.epoch, listOf(message), 3L)
|
||||
// The wrap is authored by the channel plane, never the author: nothing outside the
|
||||
// community learns the rumor id.
|
||||
assertEquals(plane.key.publicKeyHex, delete.pubKey)
|
||||
assertEquals(ConcordStreamEnvelope.KIND_WRAP, delete.kind)
|
||||
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(delete))
|
||||
val kind5 = captured.single { it.kind == 5 }
|
||||
assertEquals(listOf(message.id), kind5.tags.filter { it[0] == "e" }.map { it[1] })
|
||||
assertEquals(listOf("9"), kind5.tags.filter { it[0] == "k" }.map { it[1] })
|
||||
assertTrue(ChannelChat.isBoundTo(kind5, plane.channelIdHex, plane.epoch))
|
||||
|
||||
// A delete of an older message goes back to the plane that carried it.
|
||||
assertEquals(plane, session.channelPlaneFor(plane.channelIdHex, plane.epoch))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theHistoricalPlaneOfAnOlderMessageIsFoundForItsDelete() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val priorRoot = KeyPair().pubKey
|
||||
val session = ConcordCommunitySession(entryFor(community, heldRoots = listOf(HeldRoot(7L, priorRoot.toHexKey()))), owner.pubKey)
|
||||
community.genesisWraps.forEach { session.ingest(it) }
|
||||
val prior = session.channelPlaneFor(community.generalChannelIdHex, 7L)
|
||||
assertEquals(ConcordActions.publicChannel(priorRoot, community.generalChannelId, 7L).publicKeyHex, prior?.key?.publicKeyHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatIngestDropsOtherPlanesKindsAndPlaintextSeals() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val captured = mutableListOf<Event>()
|
||||
val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor }
|
||||
community.genesisWraps.forEach { session.ingest(it) }
|
||||
val plane = session.currentChannelPlane(community.generalChannelIdHex)!!
|
||||
val binding = arrayOf(arrayOf("channel", plane.channelIdHex), arrayOf("epoch", plane.epoch.toString()))
|
||||
|
||||
// A channel key-holder forging a Control edition (kind 3308) into the chat plane.
|
||||
val forgedControl = RumorAssembler.assembleRumor<Event>(owner.pubKey, 2L, 3308, binding, "{}")
|
||||
session.ingest(ConcordStreamEnvelope.wrap(forgedControl, plane.key, owner, encrypted = true))
|
||||
// A Guestbook join (3306) likewise.
|
||||
val forgedJoin = RumorAssembler.assembleRumor<Event>(owner.pubKey, 3L, 3306, binding, "join")
|
||||
session.ingest(ConcordStreamEnvelope.wrap(forgedJoin, plane.key, owner, encrypted = true))
|
||||
// A proper chat message in a plaintext (Control-only) seal.
|
||||
val plaintextSeal = ChannelChat.message(owner.pubKey, plane.channelIdHex, plane.epoch, "plaintext", 4L)
|
||||
session.ingest(ConcordStreamEnvelope.wrap(plaintextSeal, plane.key, owner, encrypted = false))
|
||||
// A chat message with a malformed ms.
|
||||
val badMs = RumorAssembler.assembleRumor<Event>(owner.pubKey, 5L, 9, binding + arrayOf(arrayOf("ms", "01")), "bad ms")
|
||||
session.ingest(ConcordStreamEnvelope.wrap(badMs, plane.key, owner, encrypted = true))
|
||||
|
||||
assertTrue(captured.isEmpty(), "none of these may reach the store: ${captured.map { it.kind }}")
|
||||
|
||||
// And the good path still lands.
|
||||
session.ingest(ConcordActions.buildChannelMessage(owner, plane.key, plane.channelIdHex, plane.epoch, "ok", 6L))
|
||||
assertEquals(listOf("ok"), captured.map { it.content })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelMessagesSortByTheMillisecondBasis() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val plane = ConcordActions.currentChannelPlane(entryFor(community), community.generalChannelIdHex, isPrivate = false)!!
|
||||
|
||||
suspend fun wrap(
|
||||
text: String,
|
||||
secs: Long,
|
||||
ms: Int,
|
||||
) = ConcordStreamEnvelope.wrap(ChannelChat.message(owner.pubKey, plane.channelIdHex, plane.epoch, text, secs, ms = ms), plane.key, owner, encrypted = true)
|
||||
val msgs = ConcordActions.channelMessages(listOf(wrap("third", 11, 0), wrap("second", 10, 950), wrap("first", 10, 100)), plane.key, plane.channelIdHex, plane.epoch)
|
||||
assertEquals(listOf("first", "second", "third"), msgs.map { it.content })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aChannelNameOverTheCapIsNeverMinted() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ConcordModeration.defineChannel(owner, community.controlPlane, community.communityId, secretId, ChannelEntity(name = "x".repeat(65)), community.genesisEditions, 2L, owner = community.ownerPubKey)
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ConcordModeration.defineChannel(owner, community.controlPlane, community.communityId, secretId, ChannelEntity(name = ""), community.genesisEditions, 2L, owner = community.ownerPubKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.concord
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
@@ -31,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConcordCommunitySessionTest {
|
||||
@@ -162,4 +165,39 @@ class ConcordCommunitySessionTest {
|
||||
val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
assertEquals(ConcordIngestOutcome.NOT_MINE, session.ingest(outsider.genesisWraps.first()))
|
||||
}
|
||||
|
||||
private fun entryFor(community: NewConcordCommunity) =
|
||||
ConcordCommunityListEntry(
|
||||
id = community.communityIdHex,
|
||||
owner = community.ownerPubKey,
|
||||
ownerSalt = community.ownerSalt.toHexKey(),
|
||||
root = community.communityRoot.toHexKey(),
|
||||
rootEpoch = community.rootEpoch,
|
||||
controlPk = community.controlPkHex,
|
||||
controlRoot = community.controlRoot.toHexKey(),
|
||||
relays = listOf("wss://r.example"),
|
||||
name = "Doomed",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun anOwnerTombstoneAtTheDissolvedAddressSealsTheCommunity() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Doomed", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val session = ConcordCommunitySession(entryFor(community), owner.pubKey)
|
||||
community.genesisWraps.forEach { session.ingest(it) }
|
||||
assertFalse(session.state.value!!.dissolved)
|
||||
assertTrue(session.auxStreamKeys().any { it.publicKeyHex == session.dissolvedAddress }, "the grave must be AUTHed for")
|
||||
|
||||
// A stranger can sign at the (public) address, but only the owner's tombstone counts.
|
||||
val stranger = NostrSignerInternal(KeyPair())
|
||||
session.ingest(ConcordDissolution.build(stranger, community.communityIdHex))
|
||||
assertFalse(session.state.value!!.dissolved)
|
||||
|
||||
session.ingest(ConcordDissolution.build(owner, community.communityIdHex))
|
||||
assertTrue(session.state.value!!.dissolved)
|
||||
|
||||
// One-way: a later control fold never clears it.
|
||||
community.genesisWraps.forEach { session.ingest(it) }
|
||||
assertTrue(session.state.value!!.dissolved)
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -26,8 +26,11 @@ import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragments
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@@ -131,4 +134,29 @@ class ConcordListLateArrivalTest {
|
||||
assertEquals(1, state.liveCommunities.value.size)
|
||||
assertEquals(alpha, state.liveCommunities.value[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lateArrivingFragmentDecryptsAndSurfaces() =
|
||||
runTest {
|
||||
val cache = StubCache()
|
||||
val state =
|
||||
ConcordChannelListState(
|
||||
signer = signer,
|
||||
cache = cache,
|
||||
scope = CoroutineScope(Dispatchers.Unconfined),
|
||||
settings = NoBackupRepository(),
|
||||
)
|
||||
assertEquals(emptyList(), state.liveCommunities.value)
|
||||
|
||||
// A relay delivers fragment 0 of the kind-33302 List into the note the state watches.
|
||||
val internal = ConcordCommunityList.encodeInternal(listOf(entry(alpha, "Alpha")))
|
||||
val fragment = ConcordCommunityListFragmentEvent.create(signer, 0, ConcordListFragments.pack(internal).single())
|
||||
val author = User(signer.pubKey) { addr -> Note(addr.toValue()) }
|
||||
cache.getOrCreateAddressableNote(fragment.address()).loadEvent(fragment, author, emptyList())
|
||||
|
||||
withTimeout(5000) {
|
||||
while (state.liveCommunities.value.isEmpty()) yield()
|
||||
}
|
||||
assertEquals(listOf(alpha), state.liveCommunities.value.map { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.concord
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@@ -42,13 +44,20 @@ class ConcordMembershipTest {
|
||||
author: String = owner,
|
||||
) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0)
|
||||
|
||||
private val communityId = "cc".repeat(32).hexToByteArray()
|
||||
|
||||
private val authority =
|
||||
AuthorityResolver.resolve(
|
||||
listOf(
|
||||
ed(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""), // KICK|BAN|MANAGE_ROLES
|
||||
ed(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$admin","role_ids":["$adminRole"]}"""),
|
||||
ed(ControlEntityKind.BANLIST, "44".repeat(32), """["$banned"]"""),
|
||||
ed(
|
||||
ControlEntityKind.GRANT,
|
||||
ConcordKeyDerivation.grantCoordinate(communityId, admin.hexToByteArray()).toHexKey(),
|
||||
"""{"member":"$admin","role_ids":["$adminRole"]}""",
|
||||
),
|
||||
ed(ControlEntityKind.BANLIST, ConcordKeyDerivation.banlistCoordinate(communityId).toHexKey(), """["$banned"]"""),
|
||||
),
|
||||
communityId,
|
||||
owner,
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ class ConcordPlaneRegistryTest {
|
||||
assertEquals(community.communityIdHex, routedControl.plane.communityId)
|
||||
|
||||
// After folding + registering channels, a channel message routes to CHANNEL.
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
|
||||
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey)
|
||||
registry.registerChannels(entry, state)
|
||||
|
||||
val channel = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch)
|
||||
|
||||
+5
-4
@@ -78,7 +78,7 @@ class ConcordRollbackFloorTest {
|
||||
// new epoch's plane is split and addressed by the derived signer, not the root.
|
||||
val newControlRoot = ByteArray(32) { 0x44 }
|
||||
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
|
||||
val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey)
|
||||
val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, community.ownerPubKey)
|
||||
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
@@ -145,7 +145,7 @@ class ConcordRollbackFloorTest {
|
||||
val newControlRoot = ByteArray(32) { 0x44 }
|
||||
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
|
||||
// Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives.
|
||||
val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl, community.ownerPubKey)
|
||||
val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl, community.communityId, community.ownerPubKey)
|
||||
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
@@ -186,7 +186,7 @@ class ConcordRollbackFloorTest {
|
||||
// new epoch's plane is split and addressed by the derived signer, not the root.
|
||||
val newControlRoot = ByteArray(32) { 0x44 }
|
||||
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
|
||||
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey)
|
||||
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, community.ownerPubKey)
|
||||
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
@@ -246,10 +246,11 @@ class ConcordRollbackFloorTest {
|
||||
val floors =
|
||||
ConcordCommunityState.authorizedHeads(
|
||||
ConcordActions.controlEditions(community.genesisWraps + rogueEdit, community.controlPlane),
|
||||
community.communityId,
|
||||
community.ownerPubKey,
|
||||
)
|
||||
|
||||
val metadataFloor = floors[community.communityIdHex]
|
||||
assertEquals(0L, metadataFloor?.version, "an unauthorized edition must not raise the floor")
|
||||
assertEquals(1L, metadataFloor?.version, "an unauthorized edition must not raise the floor")
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -107,10 +107,11 @@ class LatestEventCacheStoreTest {
|
||||
assertEquals("latestUserMetadata", LatestEventSlot.USER_METADATA.prefKey)
|
||||
assertEquals("latestContactList", LatestEventSlot.CONTACT_LIST.prefKey)
|
||||
assertEquals("latestNIP65RelayList", LatestEventSlot.NIP65_RELAY_LIST.prefKey)
|
||||
assertEquals(26, LatestEventSlot.entries.size)
|
||||
assertEquals("latestConcordListFragments", LatestEventSlot.CONCORD_LIST_FRAGMENTS.prefKey)
|
||||
assertEquals(27, LatestEventSlot.entries.size)
|
||||
assertEquals(
|
||||
"prefKeys must be unique",
|
||||
26,
|
||||
27,
|
||||
LatestEventSlot.entries
|
||||
.map { it.prefKey }
|
||||
.toSet()
|
||||
|
||||
+1
-1
@@ -209,7 +209,7 @@ class LegacyKeyTableTest {
|
||||
)
|
||||
|
||||
assertEquals(FollowListSlot.entries.size, TopNavFollowListStore.legacyTable.keys.size)
|
||||
assertEquals(LatestEventSlot.entries.size, LatestEventCacheStore.legacyTable.keys.size)
|
||||
assertEquals(LatestEventSlot.entries.count { it.existedInLegacyPrefs }, LatestEventCacheStore.legacyTable.keys.size)
|
||||
}
|
||||
|
||||
/** Several tables share one store, so their markers must not collide. */
|
||||
|
||||
@@ -599,6 +599,7 @@
|
||||
<string name="concord_edit_banner_hint">Add a banner</string>
|
||||
<string name="concord_edit_relays_desc">Where this community's encrypted planes are published and read.</string>
|
||||
<string name="concord_dissolved_read_only">This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted.</string>
|
||||
<string name="concord_private_channel_no_key">This is a private channel and you don't hold its key, so you can't read it or post here.</string>
|
||||
<string name="concord_create_name">Name</string>
|
||||
<string name="concord_create_about">About (optional)</string>
|
||||
<string name="concord_ban_user">Ban</string>
|
||||
|
||||
+3
-3
@@ -515,10 +515,10 @@ class AccountViewModel(
|
||||
reaction: String,
|
||||
) {
|
||||
// Concord messages are encrypted: a public kind-7 would e-tag the private rumor id onto
|
||||
// public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an
|
||||
// existing Concord reaction is a follow-up; for now this only adds one.)
|
||||
// public relays. Route the reaction through a channel-plane wrap instead; tapping it again
|
||||
// retracts it with an in-channel kind-5 delete (CORD-01), never a NIP-17 one.
|
||||
if (note.inGatherers?.any { it is ConcordChannel } == true) {
|
||||
launchSigner { account.concord.reactToConcordMessage(note, reaction) }
|
||||
launchSigner { account.concord.toggleConcordReaction(note, reaction) }
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Concord spec conformance review (spec `b84554e`, 2026-08-15)
|
||||
|
||||
## Why
|
||||
|
||||
Our Concord implementation last tracked the spec at `bbc67b6` (2026-08-06, CORD-02 §2
|
||||
`control_root`). Since then the spec moved on, and several sections that predate that
|
||||
point were never implemented. This review compares every CORD against
|
||||
<https://github.com/concord-protocol/concord> at `b84554e`, cross-checked against the
|
||||
Armada reference client (`soapbox-pub/armada` at `7588884`, 2026-09-28), and records what
|
||||
we fixed and what is left.
|
||||
|
||||
Method: one audit per spec area (CORD-01/03, CORD-02 §2/§5/§6 + CORD-04, CORD-02 §7/§9 +
|
||||
CORD-05/06, CORD-07/08), each reading our code against the spec text and against
|
||||
Armada. CORD-02 §8 (the Community List) was reviewed by hand against Armada's
|
||||
`listFrag.ts` / `communityList.ts`. Status legend: **fixed** (this branch, with tests),
|
||||
**open** (not done, with the reason).
|
||||
|
||||
## What changed upstream since our last pass
|
||||
|
||||
| Commit | Date | Change | Our state before this review |
|
||||
|---|---|---|---|
|
||||
| `d584686` | 07-27 | CORD-02 §9: the dissolution tombstone's `eid` is the `community_id`; verifiers MUST refuse anything else, including the old all-zero `eid` | Not implemented at all: we never derived `dissolved_pk`, never read or wrote a tombstone, and folded a vsk-10 *Control Plane* edition as dissolution with no `eid` check |
|
||||
| `e8c4eeb` | 08-02 | CORD-04 §7: provable, rotation-proof Pins (vsk 11, `PIN_MESSAGES` bit 11, `concord/pins` coordinate, NIP-44 key-disclosure proof bundles) | Missing (only the permission bit existed) |
|
||||
| `93fbecf` | 08-03 | CORD-08: Disappearing Messages (`message_expiration` in metadata, NIP-40 tags on rumor + wrap, kind 1740 timer notice) | Missing, and worse: a metadata edit dropped the field, silently turning off Armada's default 30-day timer for everyone |
|
||||
| `96f0647`, `bbc67b6` | 08-06 | CORD-02 §2 `control_root` write gate | Implemented (v1.14.0) |
|
||||
| `759448a`, `ee6f639` | 08-11/15 | CORD-02 §8: the Community List becomes fragmented kind **33302** (13302 retired), unpadded base64url for every 32-byte value, `seed` omitted when equal to `current`, mandatory tombstones, read-modify-write, a byte ceiling instead of a 50-entry cap | Still on 13302, hex, no tombstones (leaving just dropped the entry) |
|
||||
| `01.md` | 08-11 | Encrypted application documents may choose their own encoding (only §8 does) | n/a |
|
||||
|
||||
Still-open upstream PRs worth tracking: **#23** (authenticate `community_root` at accept;
|
||||
would make our stranded-recovery root move an explicit MUST violation), **#22**
|
||||
(community-owned `av_brokers` in metadata), **#17** (Community Signals, vsk 12),
|
||||
**#16** (CORD-09 blinded mention locators), **#7** (drop the `ms` tag). #12 (split
|
||||
read/write planes) is superseded by the merged `control_root` design.
|
||||
|
||||
## Findings
|
||||
|
||||
Ranked security > interop > feature inside each group.
|
||||
|
||||
### Security
|
||||
|
||||
| # | Spec | Finding | Status |
|
||||
|---|---|---|---|
|
||||
| S1 | 02 §9 | Dissolution: no `dissolved_pk` plane, no `eid` binding, spec tombstone (chainless, no `ev`) could not even parse; a vsk-10 Control Plane edition dissolved with no binding check | **fixed** — `ConcordDissolution` (derive, build, verify with `eid == community_id`, 20014 seal, owner author); session + planner subscribe the plane; CLI `amy concord dissolve`; the Control Plane fold no longer reads vsk 10 |
|
||||
| S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | **fixed** — `ConcordActions.currentChannelPlane`/`historicalChannelPlanes` derive a private channel only from the entry's held `privateChannels` key at its channel epoch (never the root plane); session, planner, plane registry, app verbs and CLI all go through it; a keyless private channel has no plane (`ConcordChannel.keyHeld`/`canPost()` false, composer replaced by a notice, `amy concord send` → `no_channel_key`); held keys adopt in place, and invite-delivered keys are stored on join. Creating private channels stays open (F7) |
|
||||
| S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | **fixed** — `ChannelChat.delete` (examples §2.4: binding + `e` per target + `k` per kind) sealed 20013 on the channel plane (`ConcordActions.buildChannelDelete`); `Account.delete`/`deletePrivately` route Concord notes (messages, replies, reactions) to `AccountConcordActions.deleteConcordRumors`, each target on the plane of its bound epoch (Armada always uses the current plane); tapping an own Concord reaction retracts it the same way |
|
||||
| S4 | 06 §2 | Stranded recovery adopts a bundle's newer `community_root` with no continuity or authority check — a link creator can relocate every member who joined through their link | **fixed** — `ConcordStrandedRecovery` only detects (`isStranded`); the background sweep never moves the base (exposes `strandedConcordCommunities`); moving forward from a bundle needs the user to re-open the link (`joinConcordViaInvite`, `amy concord recover --rejoin`). PR #23's accept-time root gate not implemented (text unavailable; genuine owner editions re-wrap into any plane, so it needs the PR's exact rule) |
|
||||
| S5 | 04 §1 | Grant `eid` never checked against `grant_locator(cid, member)`; a second grant chain at a random coordinate overrides the canonical one, order-dependent | **fixed** — `AuthorityResolver.resolve` / `ConcordCommunityState.fold` / `authorizedHeads` take the `community_id`; a Grant edition counts only when its `eid == grant_locator(cid, content.member)` |
|
||||
| S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist `eid` unchecked | **fixed** — the Banlist folds to ONE authority-gated head at `banlist_locator(cid)`; the rank-delta rule is kept; re-heal = the writer re-applying atop the winner (`ConcordModeration.ban` again after refold) |
|
||||
| S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | **fixed** — `EditionFold.foldEntityGated`/`foldGated` take an author rank: authority first, then rumor id, both in the head pick (Armada `pickHead`) and in the walk's anchor/next-link choice. The walk part goes past Armada (whose `version.fold` walks on rumor id only, so a lower-id fork can strand an edition chained on the owner's sibling) — spec followed |
|
||||
| S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | **fixed** — metadata is read only at `eid == community_id` (a fold gate, `AuthorityResolver.isWellFormed`) |
|
||||
| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | **fixed** — control: `ControlEdition.fromOpened` refuses a non-20014 seal; used by the session, `ConcordActions.controlEditions` (CLI) and Refounding compaction; chat: `ChannelChat.acceptOpened` requires a 20013 seal, a `CHAT_KINDS` rumor (9, 1111, 7, 5, 3302, 23311, 1740), the strict binding and a well-formed `ms`, for stored and typing wraps; `EventCache.consumeConcordRumor` refuses non-chat kinds too |
|
||||
| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | **fixed** — `ConcordStreamEnvelope` seal/wrap refuse a plaintext over 65,535 UTF-8 bytes (Armada `encryptChecked`), and open refuses an extended-format payload before decrypting |
|
||||
| S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | **fixed** — `ConcordInviteBundle.bound`: >256 channels refused, `relays` de-duplicated + truncated to 5, applied in `parse` (link bundles) and `ConcordDirectInvite.parse`; fragments decode ≤3 relays |
|
||||
| S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | **fixed** — `compactControlPlane(…, mustCarry)` throws `IncompleteControlPlaneException` on a missing honored head; app sweeps the plane paged (majority of relays DRAINED) and CLI pages it; rekey chunks are `publishAndConfirm`ed first, compaction published only after |
|
||||
| S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | **fixed** — dissolved check in `drainConcordRekeys`, `recoverStrandedConcordCommunities`, `refoundConcordCommunity`, the explicit rejoin, and CLI `rekey`/`recover`/`refound` (`ConcordCommands.isDissolved`) |
|
||||
| S14 | 05 §2 | `classify` trusts the relay filter: no signature, `pubkey == link_signer`, or `d == ""` check; a relay can forge a revocation | **fixed** — `classify(wraps, linkSignerPubKey, token)` keeps only events with kind 33301, author == link signer, `d == ""` and a valid signature (`isAtCoordinate`); every caller passes the signer |
|
||||
|
||||
### Interop (Armada drops or diverges)
|
||||
|
||||
| # | Spec | Finding | Status |
|
||||
|---|---|---|---|
|
||||
| I1 | 02 §8 | Community List on retired 13302, hex, no fragments, no tombstones | in progress (this branch) |
|
||||
| I2 | 02 §6 | Metadata/Channel edits rebuilt from scratch, wiping `custom`, `message_expiration` (CORD-08), `av_brokers` | **fixed** — `ConcordJson.encodePreserving` lays every edit over the authorized head; metadata/channel forms start from the folded entity |
|
||||
| I3 | 03 §2 | Per-channel `voice` flag still modeled and rendered (every Channel is callable since `23dcea5`) | **fixed** — field removed (rides through as an unknown key), Mic icon and blank-preview special case removed |
|
||||
| I4 | 04 §1/§5 | `vac` never written or verified — Armada drops every non-owner edition we author | **fixed** — `ConcordModeration` stamps every non-owner edition with `AuthorityCitations.forActor` (own grant coordinate, folded head version + hash); every fold gate (roles, grants, banlist, metadata, channels, unmodeled kinds, floors/compaction) requires it per Armada `citationSatisfied` |
|
||||
| I5 | 04 §2 | Role content lacks `role_id`; Armada ignores every role we mint | **fixed** — `RoleEntity.roleId` written by `defineRole`; read accepts a legacy role without it, refuses a mismatching one |
|
||||
| I6 | 04 §1 | First edition is v0; spec says versions start at 1 | **fixed** — genesis and every new entity start at v1; v0 chains still read |
|
||||
| I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | **fixed** — a canonical unmodeled `vsk` parses with `entityKind == null` + raw `vsk`; floors and compaction carry its gated head verbatim (11 by `PIN_MESSAGES`, 12 by `MANAGE_CHANNELS`, others by any staff bit). vsk 6/7/9/10 are no longer parsed as Control editions |
|
||||
| I8 | 06 | Rekey `chunk` index is 0-based; Armada requires 1-based and drops all our Refoundings | **fixed** — `ConcordRekey.tags` takes a 1-based index (`require 1..n`); `chunkOf` parses strict decimals and refuses 0 / `i > n`; receivers drop malformed chunks |
|
||||
| I9 | 06 §3 | Rotations carry no `vac` | **fixed** — rotations carry `vac` on every chunk (`ConcordRotationAuthority.citationFor`, owner none); receivers require `ConcordReceive.isHonoredRotation` (BAN + `citationSatisfied`, Armada semantics) in the app drain and CLI `rekey`; a rotator whose chunks cite different Grants is dropped |
|
||||
| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | **fixed** — `ConcordRekey.chunkBlobs` budgets the rumor JSON at 40,960 bytes (Armada `REKEY_RUMOR_MAX_BYTES`) plus the 120 count cap; test pins the seal (wrap plaintext) ≤ 65,535 with 136-byte blobs |
|
||||
| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | **fixed** — `encodeFragment` truncates non-stock lists to 3 (stock set stays a flag); `decodeFragment` refuses count > 3 |
|
||||
| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | **fixed** — `findNewRoot` converges on the lowest authorized root (`accept` filter before `converge`); sessions watch the current epoch's own rekey address and `drainConcordRekeys` heals down-only (`ConcordReceive.withHealedRoot`), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: `canonicalHeldRoots`); retries reuse reserved keys (`ConcordRefounding.reserveKeys`; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys (no private channels yet, F7) |
|
||||
| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | **fixed** — exactly one `channel` and one `epoch` tag, epoch compared as its canonical decimal string (Armada `uniqueTag`/`checkChannelBinding`); builders drop binding tags smuggled in `extraTags` |
|
||||
| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | **fixed** — any gated channel edition with `deleted:true` retires the channel for good (Armada `everDeleted`); the channel gate refuses an empty or >64-byte name so the fold falls back to the previous candidate, and `defineChannel`/create/rename refuse to mint one |
|
||||
| I15 | 02 §4 | No `ms` tag on chat rumors | **fixed** — every `ChannelChat` rumor carries `["ms", 0..999]` after the binding; malformed/duplicated `ms` drops the rumor; `channelMessages` and edit recency order by `created_at*1000+ms` (the shared feed still sorts by `created_at`; open PR #7 may drop `ms`) |
|
||||
| I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | **fixed** — four-element `q` (the `p` credit stays, as Armada keeps it). Also CORD-03 §3: your own kind-1111 thread replies can now be edited (kind 3302) like kind-9 messages |
|
||||
| I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | **fixed** — `ConcordLimits`; refused on write (factory, `ConcordModeration`, app verbs, CLI) and enforced at fold like Armada: over-cap role/metadata editions fall back, a Grant's `role_ids` trim to 64, the Community keeps its 100 lowest `role_id`s. Also: `ev`/`vac`/`vsk` must be canonical decimals and a duplicate `vsk`/`eid`/`ev`/`ep`/`vac` invalidates the edition; CLI knows `VIEW_AUDIT_LOG`/`MENTION_EVERYONE`/`PIN_MESSAGES`; the app's Admin role matches Armada's `ADMIN_ALL` |
|
||||
| I18 | 05 §1, §4 | Join doesn't echo invite attribution; CLI join publishes no Guestbook Join; Invite List merge lets the patch win; malformed tombstones dropped | **fixed** — join echoes `creator_npub`/`label` in the Guestbook Join (mints now set `creator_npub`); `amy concord join` publishes a Guestbook Join; Invite List merge is first-wins per token; untyped tombstones carried as `opaqueTombstones` and still retire their token |
|
||||
|
||||
### Features
|
||||
|
||||
| # | Spec | Finding | Status |
|
||||
|---|---|---|---|
|
||||
| F1 | 04 §7 | Pins | open → pins batch |
|
||||
| F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | metadata field + parse **fixed**; the rest open → chat-plane batch |
|
||||
| F3 | 07 | A/V calls: only key derivation, the 27235 grant and 23313 presence builders exist; no broker/SFU client, no media E2EE. Needs a LiveKit client whose license must be checked first | open — out of scope for this pass |
|
||||
| F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | open → chat-plane batch (quartz only) |
|
||||
| F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | open |
|
||||
| F6 | 05 §6 | Direct invites: wire format only, no send/receive | open |
|
||||
| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | open (depends on S2) |
|
||||
| F8 | 06 §2, 02 §8 | Walk forward from `seed`; we still keep intermediate roots in a `held_roots` List extension the spec says doesn't belong there | open |
|
||||
| F9 | 04 §6 | Kick (kind 3309) | open |
|
||||
| F10 | 03 | WebXDC (kind 3310) | open |
|
||||
|
||||
## Spec issues to raise upstream
|
||||
|
||||
- CORD-06 §1 counts rekey capacity in blobs ("up to 120 participants per event"), but 120 base
|
||||
blobs overflow NIP-44's 65,535-byte plaintext once wrapped; the spec should state the byte budget
|
||||
(Armada uses a 40,960-byte rumor ceiling).
|
||||
- The rekey blob plaintext is base64-encoded before NIP-44 (signer APIs take strings); unpinned by
|
||||
the spec, as Armada's own comment notes.
|
||||
- Rekey seal kind (20013) and the `chunk` indexing base are implied by examples only; worth a MUST.
|
||||
- 02 §8 and examples §6.2 cite "a dissolution payload (CORD-06 §1)", but CORD-06 defines no
|
||||
such payload, and Armada has none. Dangling reference.
|
||||
- CORD-07 §2 should require a nonce in the 27235 grant (Armada already adds one): two
|
||||
members requesting in the same second otherwise produce the same event id and collide
|
||||
in the broker's mandatory replay set.
|
||||
+7
-2
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord02Community
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
@@ -86,6 +87,10 @@ object ConcordCommunityFactory {
|
||||
relays: List<String> = emptyList(),
|
||||
icon: ImagePointer? = null,
|
||||
): NewConcordCommunity {
|
||||
// CORD-02 §6 caps, which every reader also enforces at fold: an over-long genesis name
|
||||
// would leave the community with no metadata at all.
|
||||
require(ConcordLimits.nameFits(name)) { "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" }
|
||||
require(ConcordLimits.descriptionFits(description)) { "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes" }
|
||||
val ownerXOnly = ownerSigner.pubKey.hexToByteArray()
|
||||
val ownerSalt = ConcordKeyDerivation.newOwnerSalt()
|
||||
val communityId = ConcordKeyDerivation.communityId(ownerXOnly, ownerSalt)
|
||||
@@ -114,7 +119,7 @@ object ConcordCommunityFactory {
|
||||
authorPubKey = ownerSigner.pubKey,
|
||||
entityKind = ControlEntityKind.METADATA,
|
||||
entityId = communityId, // metadata eid == community id
|
||||
version = 0,
|
||||
version = 1,
|
||||
prevHash = null,
|
||||
content = metadataJson,
|
||||
createdAt = createdAt,
|
||||
@@ -124,7 +129,7 @@ object ConcordCommunityFactory {
|
||||
authorPubKey = ownerSigner.pubKey,
|
||||
entityKind = ControlEntityKind.CHANNEL,
|
||||
entityId = generalChannelId, // channel eid == channel id
|
||||
version = 0,
|
||||
version = 1,
|
||||
prevHash = null,
|
||||
content = channelJson,
|
||||
createdAt = createdAt,
|
||||
|
||||
+76
@@ -126,6 +126,29 @@ class ConcordListResidue(
|
||||
val tombstones: List<JsonObject> = emptyList(),
|
||||
val unparsedEntries: List<JsonObject> = emptyList(),
|
||||
) {
|
||||
/**
|
||||
* This residue with [communityId] tombstoned at [removedAtMs] (CORD-02 §8). There is exactly
|
||||
* one tombstone per community: a later removal replaces an earlier one (keeping its unknown
|
||||
* keys), an earlier one changes nothing.
|
||||
*/
|
||||
fun withTombstone(
|
||||
communityId: String,
|
||||
removedAtMs: Long,
|
||||
): ConcordListResidue {
|
||||
val prior = tombstones.firstOrNull { (it["community_id"] as? JsonPrimitive)?.contentOrNull == communityId }
|
||||
val priorAt = (prior?.get("removed_at") as? JsonPrimitive)?.longOrNull
|
||||
if (priorAt != null && priorAt >= removedAtMs) return this
|
||||
val next = JsonObject((prior ?: NoExtras) + mapOf("community_id" to JsonPrimitive(communityId), "removed_at" to JsonPrimitive(removedAtMs)))
|
||||
return ConcordListResidue(extras, tombstones.filterNot { it === prior } + next, unparsedEntries)
|
||||
}
|
||||
|
||||
/** The latest `removed_at` this residue holds for [communityId], or null when it was never left. */
|
||||
fun removedAt(communityId: String): Long? =
|
||||
tombstones
|
||||
.filter { (it["community_id"] as? JsonPrimitive)?.contentOrNull == communityId }
|
||||
.mapNotNull { (it["removed_at"] as? JsonPrimitive)?.longOrNull }
|
||||
.maxOrNull()
|
||||
|
||||
companion object {
|
||||
val EMPTY = ConcordListResidue()
|
||||
}
|
||||
@@ -434,6 +457,39 @@ object ConcordCommunityList {
|
||||
return ConcordJson.instance.encodeToString(JsonObject.serializer(), merged)
|
||||
}
|
||||
|
||||
/**
|
||||
* [encode] as a JSON object: the internal document shape [ConcordListFragments] merges and
|
||||
* packs into kind-33302 fragments.
|
||||
*/
|
||||
fun encodeInternal(
|
||||
entries: List<ConcordCommunityListEntry>,
|
||||
residue: ConcordListResidue = ConcordListResidue.EMPTY,
|
||||
): JsonObject = ConcordJson.instance.parseToJsonElement(encode(entries, residue)).jsonObject
|
||||
|
||||
/**
|
||||
* The List as a reader sees it (CORD-02 §8): the union of the kind-33302 fragments in [set]
|
||||
* and, when one exists, the retired kind-13302 document's [legacyPlaintext] — read as a rescue
|
||||
* source, so a membership only the old event carries stays joined until a write migrates it.
|
||||
* A legacy document that does not parse contributes nothing.
|
||||
*/
|
||||
fun readWithLegacy(
|
||||
set: ConcordListFragmentSet,
|
||||
legacyPlaintext: String?,
|
||||
): JsonObject {
|
||||
val legacy =
|
||||
legacyPlaintext?.let {
|
||||
try {
|
||||
ConcordJson.instance.parseToJsonElement(it) as? JsonObject
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
} ?: return set.doc
|
||||
return ConcordListFragments.mergeDocs(legacy, set.doc)
|
||||
}
|
||||
|
||||
/** Decodes an internal document (a merged fragment set, or a legacy 13302 plaintext). */
|
||||
fun decodeDocument(doc: JsonObject): ConcordCommunityListDocument = decodeDocument(ConcordJson.instance.encodeToString(JsonObject.serializer(), doc))
|
||||
|
||||
/**
|
||||
* Parses the decrypted plaintext JSON document back into live entries, or empty on
|
||||
* failure. An entry is live unless a tombstone for the same community removed it
|
||||
@@ -585,6 +641,26 @@ object ConcordCommunityList {
|
||||
residue = residue,
|
||||
)
|
||||
|
||||
/** Copy of this entry with [addedAt] (ms); every other field untouched. */
|
||||
fun ConcordCommunityListEntry.withAddedAt(addedAt: Long) =
|
||||
ConcordCommunityListEntry(
|
||||
id = id,
|
||||
owner = owner,
|
||||
ownerSalt = ownerSalt,
|
||||
root = root,
|
||||
rootEpoch = rootEpoch,
|
||||
controlPk = controlPk,
|
||||
controlRoot = controlRoot,
|
||||
heldRoots = heldRoots,
|
||||
privateChannels = privateChannels,
|
||||
relays = relays,
|
||||
name = name,
|
||||
addedAt = addedAt,
|
||||
inviteRef = inviteRef,
|
||||
excludedAtEpoch = excludedAtEpoch,
|
||||
residue = residue,
|
||||
)
|
||||
|
||||
/** Copy of this entry carrying [inviteRef]; every other field untouched. */
|
||||
fun ConcordCommunityListEntry.withInviteRef(inviteRef: String?) =
|
||||
ConcordCommunityListEntry(
|
||||
|
||||
+16
@@ -29,8 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* **Retired** (CORD-02 §8): the single-event Community List, superseded by the fragmented kind
|
||||
* 33302 [ConcordCommunityListFragmentEvent] once it outgrew one event. Still read, as a rescue
|
||||
* source unioned into the fragments, so memberships only this event carries are migrated by the
|
||||
* next write; never written.
|
||||
*
|
||||
* The member's private, self-encrypted list of joined Concord communities (kind
|
||||
* 13302, CORD-05). A replaceable event whose
|
||||
* `content` is the NIP-44 self-encryption of the [ConcordCommunityListEntry] JSON
|
||||
@@ -59,6 +65,16 @@ class ConcordCommunityListEvent(
|
||||
|
||||
override fun isContentEncoded() = true
|
||||
|
||||
/** The decrypted plaintext (the internal document shape), or null when it does not open. */
|
||||
suspend fun decryptPlaintext(signer: NostrSigner): String? =
|
||||
try {
|
||||
signer.nip44Decrypt(content, signer.pubKey)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Decrypts this list's entries with [signer], or empty on failure / wrong key. */
|
||||
suspend fun decrypt(signer: NostrSigner): List<ConcordCommunityListEntry> = decryptDocument(signer).entries
|
||||
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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.cord02Community
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.ContentChange
|
||||
import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* One fragment of a member's Community List (CORD-02 §8, kind 33302): addressable at
|
||||
* `d` = the fragment index in decimal, NIP-44-encrypted to self, signed by the member's real key.
|
||||
*
|
||||
* The List is split across as many of these as it needs — it has no membership limit, only a
|
||||
* per-event byte ceiling — and a reader unions every fragment below the declared `frags` count
|
||||
* ([ConcordListFragmentSet]). It supersedes the single replaceable kind-13302
|
||||
* [ConcordCommunityListEvent], which a replaceable kind could never fragment.
|
||||
*/
|
||||
@Immutable
|
||||
class ConcordCommunityListFragmentEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
DiffableEvent<ConcordCommunityListDiff> {
|
||||
override fun diffFrom(older: Event): ConcordCommunityListDiff? {
|
||||
if (older !is ConcordCommunityListFragmentEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null
|
||||
return ConcordCommunityListDiff(ContentChange.between(older.content, content))
|
||||
}
|
||||
|
||||
override fun isContentEncoded() = true
|
||||
|
||||
/**
|
||||
* The fragment index this event occupies, or null when its `d` is not a canonical decimal
|
||||
* (no sign, no leading zeros) — such an event sits at no index and is ignored.
|
||||
*/
|
||||
fun index(): Int? = parseIndex(dTag())
|
||||
|
||||
/**
|
||||
* The decrypted plaintext, or null when it does not open for [signer]. A null makes the
|
||||
* fragment's index unreadable, which blocks any repack — so a transient signer failure (a
|
||||
* timed-out bunker, a backgrounded signer app) costs a write, never a membership.
|
||||
* Cancellation is rethrown.
|
||||
*/
|
||||
suspend fun decryptPlaintext(signer: NostrSigner): String? =
|
||||
try {
|
||||
signer.nip44Decrypt(content, signer.pubKey)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 33302
|
||||
const val ALT = "Private list of joined Concord communities"
|
||||
|
||||
fun createAddress(
|
||||
pubKey: HexKey,
|
||||
index: Int,
|
||||
) = Address(KIND, pubKey, index.toString())
|
||||
|
||||
fun parseIndex(d: String): Int? {
|
||||
if (d.isEmpty() || d.length > 9) return null
|
||||
if (d.length > 1 && d[0] == '0') return null
|
||||
if (!d.all { it in '0'..'9' }) return null
|
||||
return d.toInt()
|
||||
}
|
||||
|
||||
/** Encrypts [plaintext] to self and signs it as fragment [index]. */
|
||||
suspend fun create(
|
||||
signer: NostrSigner,
|
||||
index: Int,
|
||||
plaintext: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): ConcordCommunityListFragmentEvent {
|
||||
val content = signer.nip44Encrypt(plaintext, signer.pubKey)
|
||||
return signer.sign(createdAt, KIND, arrayOf(arrayOf("d", index.toString())), content)
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
-18
@@ -31,6 +31,7 @@ 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
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
|
||||
/** A channel id paired with its current folded definition. */
|
||||
data class ConcordChannel(
|
||||
@@ -57,7 +58,16 @@ data class ConcordCommunityState(
|
||||
val authority: AuthorityResolver,
|
||||
val dissolved: Boolean,
|
||||
) {
|
||||
/**
|
||||
* This state with [dissolved] set from the community's dissolution plane
|
||||
* ([ConcordDissolution.isDissolved]). One-way by the caller's contract: there is no un-dissolve.
|
||||
*/
|
||||
fun withDissolved(dissolved: Boolean): ConcordCommunityState = if (dissolved == this.dissolved) this else copy(dissolved = dissolved)
|
||||
|
||||
companion object {
|
||||
/** The Community Signals sub-kind (CORD-04 §8, upstream PR #17), carried but not modeled here. */
|
||||
private const val VSK_SIGNALS = "12"
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -71,12 +81,40 @@ data class ConcordCommunityState(
|
||||
ControlEntityKind.BANLIST -> ConcordPermissions.BAN
|
||||
ControlEntityKind.INVITE_LIVE, ControlEntityKind.INVITE_REGISTRY, ControlEntityKind.INVITE_REVOKED -> ConcordPermissions.CREATE_INVITE
|
||||
ControlEntityKind.DISSOLVED -> null
|
||||
ControlEntityKind.PIN_LIST -> ConcordPermissions.PIN_MESSAGES
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a reader honors [edition] as its entity's head: well-formed at its coordinate,
|
||||
* authored by the owner or a holder of the kind's bit, citing the Grant it acts under.
|
||||
*
|
||||
* A sub-kind we do not model is still gated — a floor or a compaction must only remember
|
||||
* editions some reader honors: a Signal by `MANAGE_CHANNELS` (the one gate Armada implements, `pause`), and anything newer by any
|
||||
* staff bit, the set whose actions are Control editions at all (CORD-04 §3).
|
||||
*/
|
||||
private fun honors(
|
||||
authority: AuthorityResolver,
|
||||
edition: ControlEdition,
|
||||
): Boolean {
|
||||
val kind = edition.entityKind ?: return honorsUnmodeled(authority, edition)
|
||||
return authority.admits(edition, requiredPermission(kind))
|
||||
}
|
||||
|
||||
private fun honorsUnmodeled(
|
||||
authority: AuthorityResolver,
|
||||
edition: ControlEdition,
|
||||
): Boolean =
|
||||
when (edition.vsk) {
|
||||
VSK_SIGNALS -> authority.admits(edition, ConcordPermissions.MANAGE_CHANNELS)
|
||||
else -> authority.isOwner(edition.author) || (authority.isStaff(edition.author) && authority.citationSatisfied(edition))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* client carries across a CORD-06 Refounding, and the set of heads a Refounding's
|
||||
* compaction re-wraps (sub-kinds we do not model included, so another client's Pins
|
||||
* or Signals survive our 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
|
||||
@@ -86,6 +124,7 @@ data class ConcordCommunityState(
|
||||
*/
|
||||
fun authorizedHeads(
|
||||
editions: Collection<ControlEdition>,
|
||||
communityId: ByteArray,
|
||||
ownerPubKey: String,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
): Map<String, EntityFloor> {
|
||||
@@ -96,15 +135,14 @@ data class ConcordCommunityState(
|
||||
// mentioned correctly keeps chain-walk semantics.
|
||||
val snapshot = editions.mapTo(HashSet(editions.size)) { it.rumorId }
|
||||
val pool = EditionFold.admissible(editions, floors, snapshot = snapshot)
|
||||
val authority = AuthorityResolver.resolve(pool, ownerPubKey)
|
||||
val authority = AuthorityResolver.resolve(pool, communityId, ownerPubKey)
|
||||
val out = HashMap<String, EntityFloor>(floors)
|
||||
for ((kind, list) in pool.groupBy { it.entityKind }) {
|
||||
val bit = requiredPermission(kind)
|
||||
for ((_, list) in pool.groupBy { it.entityKind }) {
|
||||
// Gate the CANDIDATES, don't pre-filter the chain: a rejected edition mid-chain must
|
||||
// stay inert instead of orphaning the authorized editions above it (EditionFold.candidates).
|
||||
val heads =
|
||||
EditionFold.foldGated(list, floors, snapshot = snapshot) {
|
||||
authority.isOwner(it.author) || (bit != null && authority.hasPermission(it.author, bit))
|
||||
EditionFold.foldGated(list, floors, snapshot = snapshot, rank = authority::tieBreakRank) {
|
||||
honors(authority, it)
|
||||
}
|
||||
for ((entity, head) in heads) {
|
||||
// Monotonic: a floor only ever rises. Folding epoch by epoch, an entity the
|
||||
@@ -116,8 +154,14 @@ data class ConcordCommunityState(
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds one epoch's Control Plane [editions] of the community [communityId] (which pins
|
||||
* every derived entity coordinate, CORD-04 §1) owned by [ownerPubKey] into its current
|
||||
* state, honoring the anti-rollback [floors] carried from earlier epochs.
|
||||
*/
|
||||
fun fold(
|
||||
editions: Collection<ControlEdition>,
|
||||
communityId: ByteArray,
|
||||
ownerPubKey: String,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
): ConcordCommunityState {
|
||||
@@ -133,11 +177,10 @@ data class ConcordCommunityState(
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val editions = EditionFold.admissible(editions, floors, snapshot = snapshot)
|
||||
|
||||
val heads = EditionFold.fold(editions, floors, snapshot = snapshot).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.
|
||||
val authority = AuthorityResolver.resolve(editions, ownerPubKey)
|
||||
val authority = AuthorityResolver.resolve(editions, communityId, ownerPubKey)
|
||||
|
||||
// CORD-04 §1: "an edition whose signer isn't authorized is dropped." Authority is
|
||||
// owner-rooted (the AuthorityResolver resolves it from the owner outward via the grant
|
||||
@@ -149,25 +192,46 @@ data class ConcordCommunityState(
|
||||
// remaining editions version-descending), never as a pre-filter on the chain: dropping a
|
||||
// rejected edition out of the middle of a chain permanently orphans every honest edition
|
||||
// above it, freezing the entity. See EditionFold.candidates.
|
||||
//
|
||||
// Every gate also demands the edition sit at its derived coordinate and cite the Grant
|
||||
// its author acts under (CORD-04 §5, `vac`) — AuthorityResolver.admits — and an
|
||||
// equal-version tie goes to the higher-ranked author before the rumor id (§1).
|
||||
fun foldGatedBy(
|
||||
kind: ControlEntityKind,
|
||||
bit: Int,
|
||||
): Map<String, ControlEdition> =
|
||||
EditionFold.foldGated(editions.filter { it.entityKind == kind }, floors, snapshot = snapshot) {
|
||||
authority.isOwner(it.author) || authority.hasPermission(it.author, bit)
|
||||
EditionFold.foldGated(editions.filter { it.entityKind == kind }, floors, snapshot = snapshot, rank = authority::tieBreakRank) {
|
||||
authority.admits(it, bit)
|
||||
}
|
||||
|
||||
// Metadata is one entity (== community id), gated by MANAGE_METADATA. Take the
|
||||
// highest-version gated head (guarding against strays).
|
||||
// Metadata is ONE entity, at the community_id itself (CORD-04 §1): an edition at any
|
||||
// other coordinate is not this community's metadata however high its version, so it can
|
||||
// neither shadow the chain nor bypass it (S8). Name and description caps are fold gates.
|
||||
val metadata =
|
||||
foldGatedBy(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA)
|
||||
.values
|
||||
.maxByOrNull { it.version }
|
||||
foldGatedBy(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA)[communityId.toHexKey()]
|
||||
?.let { ConcordJson.decodeOrNull<MetadataEntity>(it.content) }
|
||||
|
||||
// Channels are gated by MANAGE_CHANNELS, per channel entity, dropping the tombstoned ones.
|
||||
// The gate also enforces the name rule (non-empty, <= 64 UTF-8 bytes, CORD-03 §2): an
|
||||
// edition breaking it is unauthorized and the fold falls back to the previous candidate.
|
||||
val channelEditions = editions.filter { it.entityKind == ControlEntityKind.CHANNEL }
|
||||
// The same gate every entity folds under (well-formed, owner or MANAGE_CHANNELS holder, `vac`
|
||||
// satisfied), plus the Channel name rule: an empty or over-cap name is an edition no reader honors.
|
||||
val channelGate = { edition: ControlEdition ->
|
||||
authority.admits(edition, ConcordPermissions.MANAGE_CHANNELS) &&
|
||||
ConcordJson.decodeOrNull<ChannelEntity>(edition.content)?.hasValidName() == true
|
||||
}
|
||||
// Deletion is terminal (CORD-03 §2): any gated edition anywhere in a channel's accepted
|
||||
// chain that says `deleted` retires it for good, even if a later edition "restores" it —
|
||||
// members may already have discarded its keys, so a resurrection would split them.
|
||||
val everDeleted =
|
||||
channelEditions
|
||||
.filter { edition ->
|
||||
channelGate(edition) && ConcordJson.decodeOrNull<ChannelEntity>(edition.content)?.deleted == true
|
||||
}.mapTo(HashSet()) { it.entityIdHex }
|
||||
val channels = LinkedHashMap<String, ConcordChannel>()
|
||||
for (head in foldGatedBy(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS).values) {
|
||||
for (head in EditionFold.foldGated(channelEditions, floors, snapshot = snapshot, rank = authority::tieBreakRank, gate = channelGate).values) {
|
||||
if (head.entityIdHex in everDeleted) continue
|
||||
val def = ConcordJson.decodeOrNull<ChannelEntity>(head.content) ?: continue
|
||||
if (def.deleted) continue
|
||||
channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def)
|
||||
@@ -179,8 +243,12 @@ data class ConcordCommunityState(
|
||||
// so we take the roles the AuthorityResolver actually accepted from the owner outward.
|
||||
val roles = authority.roles()
|
||||
|
||||
// Dissolution is owner-only — a rogue tombstone must not appear to kill the community.
|
||||
val dissolved = heads.any { it.entityKind == ControlEntityKind.DISSOLVED && authority.isOwner(it.author) }
|
||||
// Dissolution is NOT read from the Control Plane. The tombstone is chainless and lives at its
|
||||
// own address (CORD-02 §9, [ConcordDissolution]) where it must also name this community in its
|
||||
// `eid`; a vsk-10 edition folded here would skip that binding check, so an owner's tombstone
|
||||
// for another community re-wrapped onto this plane would kill this one. The caller that reads
|
||||
// the dissolved plane sets [dissolved] via [withDissolved].
|
||||
val dissolved = false
|
||||
|
||||
return ConcordCommunityState(
|
||||
ownerPubKey = ownerPubKey.lowercase(),
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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.cord02Community
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EidTag
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Community Dissolution (CORD-02 §9): the owner-signed, chainless tombstone that ends a
|
||||
* Community for good.
|
||||
*
|
||||
* The tombstone is a kind-3308 rumor carrying only `["vsk","10"]` and `["eid", community_id]`,
|
||||
* plaintext-sealed (20014) by the owner and wrapped at [planeKey] — an address derived from the
|
||||
* `community_id` alone, so no epoch or key is needed to find it and a Refounding can never strand
|
||||
* the grave. It is **not** a Control Plane edition: it has no `ev`/`ep`/`vac`, and presence of one
|
||||
* valid tombstone is the whole state.
|
||||
*
|
||||
* ## The `eid` binding is the security boundary
|
||||
*
|
||||
* The plane is public: anyone holding the `community_id` (it ships in every invite) derives the
|
||||
* whole keypair and can sign wraps at it. The one thing they cannot make is an owner-signed
|
||||
* `vsk 10` rumor — but a plaintext seal re-wraps verbatim, so an owner's genuine tombstone for
|
||||
* community X could be lifted and re-wrapped at the address of any other community Y the same
|
||||
* owner runs. [isTombstoneFor] therefore requires `eid == community_id`, and refuses anything
|
||||
* else, including the all-zero placeholder earlier spec revisions used. Refusing that legacy
|
||||
* value leaves an old dissolution reading alive (the owner re-dissolves); accepting it lets any
|
||||
* multi-community owner's other communities be killed irrecoverably.
|
||||
*/
|
||||
object ConcordDissolution {
|
||||
/** The dissolution tombstone's address + keys for [communityIdHex] (CORD-02 §9, A.6). */
|
||||
fun planeKey(communityIdHex: HexKey): GroupKey = ConcordKeyDerivation.dissolvedPlaneKey(communityIdHex.hexToByteArray())
|
||||
|
||||
/** The unsigned tombstone rumor for [communityIdHex], authored by [ownerPubKey]. */
|
||||
fun rumor(
|
||||
ownerPubKey: HexKey,
|
||||
communityIdHex: HexKey,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Event =
|
||||
RumorAssembler.assembleRumor(
|
||||
ownerPubKey,
|
||||
// Chainless: exactly vsk + eid, no ev/ep/vac (CORD-02 §9).
|
||||
eventTemplate<ControlEditionEvent>(ControlEditionEvent.KIND, "", createdAt) {
|
||||
add(VskTag.assemble(ControlEntityKind.DISSOLVED))
|
||||
add(EidTag.assemble(communityIdHex.hexToByteArray()))
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Seals the tombstone with [ownerSigner] (plaintext 20014, so the owner's signature survives
|
||||
* any re-wrap) and wraps it at the community's dissolved address. Only the owner's signature
|
||||
* counts, so a non-owner [ownerSigner] produces a wrap every verifier ignores.
|
||||
*/
|
||||
suspend fun build(
|
||||
ownerSigner: NostrSigner,
|
||||
communityIdHex: HexKey,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Event {
|
||||
val plane = planeKey(communityIdHex)
|
||||
val seal = ConcordStreamEnvelope.seal(rumor(ownerSigner.pubKey, communityIdHex, createdAt), plane, ownerSigner, encrypted = false)
|
||||
return ConcordStreamEnvelope.wrapSeal(seal, plane, createdAt = createdAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [opened] is a valid tombstone for [communityIdHex] owned by [ownerPubKey]: a
|
||||
* plaintext-sealed kind-3308 rumor, authored (seal signer) by the owner, `vsk 10`, and an
|
||||
* `eid` equal to this community's id. Anything else — a wrong or all-zero `eid`, an encrypted
|
||||
* seal, another author — is noise.
|
||||
*/
|
||||
fun isTombstoneFor(
|
||||
opened: OpenedStreamEvent,
|
||||
communityIdHex: HexKey,
|
||||
ownerPubKey: HexKey,
|
||||
): Boolean {
|
||||
if (!opened.author.equals(ownerPubKey, ignoreCase = true)) return false
|
||||
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT) return false
|
||||
val rumor = opened.rumor
|
||||
if (rumor.kind != ControlEditionEvent.KIND) return false
|
||||
val vsk = rumor.tags.firstOrNull { it.size >= 2 && it[0] == VskTag.TAG_NAME }?.get(1)
|
||||
val eid = rumor.tags.firstOrNull { it.size >= 2 && it[0] == EidTag.TAG_NAME }?.get(1)
|
||||
return vsk == ControlEntityKind.DISSOLVED.wire && eid != null && eid.equals(communityIdHex, ignoreCase = true)
|
||||
}
|
||||
|
||||
/** Opens [wrap] at [communityIdHex]'s dissolved address and checks it with [isTombstoneFor]. */
|
||||
fun isTombstoneWrap(
|
||||
wrap: Event,
|
||||
communityIdHex: HexKey,
|
||||
ownerPubKey: HexKey,
|
||||
): Boolean {
|
||||
val opened = ConcordStreamEnvelope.openOrNull(wrap, planeKey(communityIdHex)) ?: return false
|
||||
return isTombstoneFor(opened, communityIdHex, ownerPubKey)
|
||||
}
|
||||
|
||||
/** True when any of [wraps] is a valid tombstone for this community (CORD-02 §9). */
|
||||
fun isDissolved(
|
||||
wraps: Collection<Event>,
|
||||
communityIdHex: HexKey,
|
||||
ownerPubKey: HexKey,
|
||||
): Boolean {
|
||||
if (wraps.isEmpty()) return false
|
||||
val plane = planeKey(communityIdHex)
|
||||
return wraps.any { wrap ->
|
||||
val opened = ConcordStreamEnvelope.openOrNull(wrap, plane) ?: return@any false
|
||||
isTombstoneFor(opened, communityIdHex, ownerPubKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* 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.cord02Community
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** Thrown when a write would need fragments this client does not hold. */
|
||||
class ConcordListIncompleteException(
|
||||
message: String,
|
||||
) : IllegalStateException(message)
|
||||
|
||||
/** Thrown when a fragment would exceed the event ceiling (CORD-02 §8) and cannot be split here. */
|
||||
class ConcordListTooLargeException(
|
||||
message: String,
|
||||
) : IllegalStateException(message)
|
||||
|
||||
/**
|
||||
* The fragments of a member's Community List as this client holds them (CORD-02 §8): per index,
|
||||
* the newest copy (newest `created_at`, ties to the lowest id, as relays resolve an addressable
|
||||
* coordinate), the declared fragment count, whether the set is complete, and their union.
|
||||
*
|
||||
* "Absence is never a fact": a missing or unreadable fragment is news not yet heard. Reading an
|
||||
* incomplete set is safe — every merge is commutative and idempotent — but a **repack** (any
|
||||
* write that changes `frags`) needs the complete set, or it silently drops every membership in
|
||||
* the fragments it never read. [planWrites] enforces that, falling back to a scoped write that
|
||||
* only rewrites the fragments holding the changed memberships.
|
||||
*/
|
||||
class ConcordListFragmentSet private constructor(
|
||||
/** The fragment count the newest held fragment declares (ties to the larger), 0 when none is held. */
|
||||
val declared: Int,
|
||||
/** Newest readable copy per index. */
|
||||
val held: Map<Int, Held>,
|
||||
/** Newest `created_at` per index, readable or not — a write must exceed it. */
|
||||
private val createdAtFloor: Map<Int, Long>,
|
||||
/** Indices whose newest copy did not decrypt or parse. */
|
||||
val unreadable: Set<Int>,
|
||||
/** A declared count past [ConcordListFragments.MAX_DECLARED_FRAGS] was clamped. */
|
||||
private val overflow: Boolean,
|
||||
) {
|
||||
class Held(
|
||||
val createdAt: Long,
|
||||
val plaintext: String,
|
||||
val fragment: ConcordListFragments.DecodedFragment,
|
||||
)
|
||||
|
||||
/** One copy of a fragment as fetched; [plaintext] is null when it did not decrypt. */
|
||||
class Copy(
|
||||
val index: Int,
|
||||
val createdAt: Long,
|
||||
val id: String,
|
||||
val plaintext: String?,
|
||||
)
|
||||
|
||||
/** One fragment to (re)publish. */
|
||||
class Write(
|
||||
val index: Int,
|
||||
val plaintext: String,
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when every index below [declared] is held and readable **and** no fragment we saw failed
|
||||
* to open. An unreadable copy may declare a larger count than any readable one, or be the only
|
||||
* copy of its index, so its presence always means "not the whole List" — never grounds for a
|
||||
* repack that would overwrite it. Vacuously true only when no fragment was seen at all.
|
||||
*/
|
||||
val complete: Boolean = !overflow && unreadable.isEmpty() && (0 until declared).all { it in held }
|
||||
|
||||
/** True when no fragment has been seen at all. */
|
||||
val isEmpty: Boolean get() = createdAtFloor.isEmpty()
|
||||
|
||||
/**
|
||||
* The union of every held fragment below [declared], in the internal shape. Fragments at or
|
||||
* past [declared] are out of range — an emptied index's stale memberships stay dormant.
|
||||
* Fragment-level unknown keys belong to the List; where two fragments carry the same key the
|
||||
* lowest index wins.
|
||||
*/
|
||||
val doc: JsonObject by lazy {
|
||||
held.keys
|
||||
.filter { it < declared }
|
||||
.sortedDescending()
|
||||
.fold(EMPTY_DOC) { acc, i -> ConcordListFragments.mergeDocs(acc, held.getValue(i).fragment.doc) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The fragments to publish so the wire holds [newDoc] (internal shape: this set's [doc] with
|
||||
* the caller's change applied — a read-modify-write, never local state alone).
|
||||
*
|
||||
* With the complete List this repacks: every fragment whose bytes change, plus an emptied
|
||||
* copy of each index a shrinking count drops (an abandoned index would come back into range
|
||||
* later). Without it, it only rewrites the held fragments that mention a changed membership
|
||||
* (or the lowest held one for a new membership), keeping the declared count.
|
||||
*
|
||||
* Every write carries a `created_at` strictly above that index's previous one.
|
||||
*/
|
||||
fun planWrites(
|
||||
newDoc: JsonObject,
|
||||
now: Long,
|
||||
): List<Write> {
|
||||
val out = ArrayList<Write>()
|
||||
|
||||
fun stamp(index: Int) = maxOf(now, (createdAtFloor[index] ?: 0L) + 1)
|
||||
|
||||
if (complete) {
|
||||
val packed = ConcordListFragments.pack(newDoc)
|
||||
for ((i, plaintext) in packed.withIndex()) {
|
||||
guardSize(i, plaintext)
|
||||
if (held[i]?.plaintext != plaintext) out.add(Write(i, plaintext, stamp(i)))
|
||||
}
|
||||
val empty = ConcordListFragments.emptyFragment(packed.size)
|
||||
for (i in packed.size until maxOf(declared, packed.size)) {
|
||||
if (held[i]?.plaintext != empty) out.add(Write(i, empty, stamp(i)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
val changed = changedIds(newDoc)
|
||||
if (changed.isEmpty()) return out
|
||||
val inRange = held.keys.filter { it < declared }.sorted()
|
||||
if (inRange.isEmpty()) throw ConcordListIncompleteException("no readable Community List fragment is held; refusing to write")
|
||||
val targets = HashMap<Int, MutableSet<String>>()
|
||||
for (id in changed) {
|
||||
val holders = inRange.filter { id in ConcordListFragments.idsIn(held.getValue(it).fragment.doc) }
|
||||
for (i in holders.ifEmpty { listOf(inRange.first()) }) targets.getOrPut(i) { HashSet() }.add(id)
|
||||
}
|
||||
for ((i, ids) in targets.entries.sortedBy { it.key }) {
|
||||
val plaintext = ConcordListFragments.rewriteFragment(held.getValue(i).fragment.doc, newDoc, ids, declared)
|
||||
guardSize(i, plaintext)
|
||||
if (held[i]?.plaintext != plaintext) out.add(Write(i, plaintext, stamp(i)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun guardSize(
|
||||
index: Int,
|
||||
plaintext: String,
|
||||
) {
|
||||
val projected = ConcordListFragments.projectedEventBytes(plaintext.encodeToByteArray().size)
|
||||
if (projected <= ConcordListFragments.EVENT_CEILING_BYTES) return
|
||||
// A write that strictly shrinks the fragment is exempt: leaving must stay possible for a
|
||||
// member already over the ceiling (CORD-02 §8).
|
||||
val previous = held[index]?.plaintext ?: throw ConcordListTooLargeException("fragment $index would be $projected bytes")
|
||||
if (plaintext.encodeToByteArray().size >= previous.encodeToByteArray().size) {
|
||||
throw ConcordListTooLargeException("fragment $index would be $projected bytes; a split needs the complete List")
|
||||
}
|
||||
}
|
||||
|
||||
/** Community ids whose entry or tombstone differs between [doc] and [newDoc]. */
|
||||
private fun changedIds(newDoc: JsonObject): Set<String> {
|
||||
fun byId(
|
||||
d: JsonObject,
|
||||
key: String,
|
||||
) = (d[key] as? JsonArray)
|
||||
.orEmpty()
|
||||
.mapNotNull { it as? JsonObject }
|
||||
.associateBy { (it["community_id"] as? JsonPrimitive)?.content.orEmpty() }
|
||||
|
||||
val out = HashSet<String>()
|
||||
for (key in listOf("entries", "tombstones")) {
|
||||
val before = byId(doc, key)
|
||||
val after = byId(newDoc, key)
|
||||
for (id in before.keys + after.keys) {
|
||||
val a = before[id]?.let { ConcordListFragments.canonicalString(it) }
|
||||
val b = after[id]?.let { ConcordListFragments.canonicalString(it) }
|
||||
if (a != b && id.isNotEmpty()) out.add(id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val EMPTY_DOC = JsonObject(mapOf("entries" to JsonArray(emptyList()), "tombstones" to JsonArray(emptyList())))
|
||||
|
||||
/** Resolves already-decrypted [copies]. Pure, for tests and callers that decrypt elsewhere. */
|
||||
fun of(copies: Collection<Copy>): ConcordListFragmentSet {
|
||||
val newest =
|
||||
copies
|
||||
.groupBy { it.index }
|
||||
.mapValues { (_, list) -> list.sortedWith(compareByDescending<Copy> { it.createdAt }.thenBy { it.id }).first() }
|
||||
val held = HashMap<Int, Held>()
|
||||
val unreadable = HashSet<Int>()
|
||||
for ((i, copy) in newest) {
|
||||
// Resolve the coordinate BEFORE decrypting: an unreadable head is a missing index,
|
||||
// never a cue to fall back to an older copy a write would then be based on.
|
||||
val text = copy.plaintext
|
||||
val decoded =
|
||||
text?.let {
|
||||
try {
|
||||
ConcordListFragments.decodeFragment(it)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (text == null || decoded == null) unreadable.add(i) else held[i] = Held(copy.createdAt, text, decoded)
|
||||
}
|
||||
var best: Held? = null
|
||||
for (h in held.values) {
|
||||
val b = best
|
||||
if (b == null || h.createdAt > b.createdAt || (h.createdAt == b.createdAt && h.fragment.frags > b.fragment.frags)) best = h
|
||||
}
|
||||
val wireDeclared = best?.fragment?.frags?.coerceAtLeast(1) ?: 0
|
||||
return ConcordListFragmentSet(
|
||||
declared = minOf(wireDeclared, ConcordListFragments.MAX_DECLARED_FRAGS),
|
||||
held = held,
|
||||
createdAtFloor = newest.mapValues { it.value.createdAt },
|
||||
unreadable = unreadable,
|
||||
overflow = wireDeclared > ConcordListFragments.MAX_DECLARED_FRAGS,
|
||||
)
|
||||
}
|
||||
|
||||
/** Decrypts [events] with [signer] and resolves them. Events at a non-canonical `d` are ignored. */
|
||||
suspend fun resolve(
|
||||
events: Collection<ConcordCommunityListFragmentEvent>,
|
||||
signer: NostrSigner,
|
||||
): ConcordListFragmentSet = resolve(events, signer.pubKey) { it.decryptPlaintext(signer) }
|
||||
|
||||
/**
|
||||
* [resolve] with a caller-supplied [decrypt], so a caller can memoize plaintext by event id
|
||||
* instead of paying a signer round trip per fragment per read. Each event id is decrypted
|
||||
* at most once per call, and only the newest copy per index is decrypted at all.
|
||||
*/
|
||||
suspend fun resolve(
|
||||
events: Collection<ConcordCommunityListFragmentEvent>,
|
||||
owner: String,
|
||||
decrypt: suspend (ConcordCommunityListFragmentEvent) -> String?,
|
||||
): ConcordListFragmentSet {
|
||||
val newestPerIndex =
|
||||
events
|
||||
.asSequence()
|
||||
.filter { it.pubKey == owner }
|
||||
.mapNotNull { e -> e.index()?.let { it to e } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, list) -> list.sortedWith(compareByDescending<ConcordCommunityListFragmentEvent> { it.createdAt }.thenBy { it.id }).first() }
|
||||
return of(newestPerIndex.map { (index, e) -> Copy(index, e.createdAt, e.id, decrypt(e)) })
|
||||
}
|
||||
|
||||
val EMPTY: ConcordListFragmentSet = of(emptyList())
|
||||
}
|
||||
}
|
||||
+613
@@ -0,0 +1,613 @@
|
||||
/*
|
||||
* 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.cord02Community
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlin.io.encoding.Base64
|
||||
|
||||
/**
|
||||
* The wire layer of the fragmented Community List (CORD-02 §8, kind 33302).
|
||||
*
|
||||
* Two JSON shapes meet here:
|
||||
*
|
||||
* - **internal** — what [ConcordCommunityList] reads and writes, and what the retired kind-13302
|
||||
* document always was: 32-byte values in lowercase hex, every snapshot carrying its own
|
||||
* `community_id`, `seed` always present. Merges ([mergeDocs]) and the canonical-bytes
|
||||
* tie-break run on this shape, as they do in the reference client.
|
||||
* - **wire** — one fragment's plaintext: `{"frags": n, "entries": […], "tombstones": […]}`, every
|
||||
* 32-byte value **this section names** as unpadded base64url (43 chars) at any depth, embedded
|
||||
* snapshots without `community_id`, `seed` omitted when it equals `current`, and entries a
|
||||
* tombstone outranks left out.
|
||||
*
|
||||
* ## Byte identity is the contract
|
||||
*
|
||||
* Two devices holding identical state must serialize identical bytes, or the tie-break flaps
|
||||
* between their republishes. So the emitter here is hand-ordered to match the reference
|
||||
* implementations (Armada `listFrag.ts`, Vector `list_frag.rs`): named fields in their declared
|
||||
* order, optional ones omitted rather than null, then unknown fields in sorted key order with
|
||||
* recursively key-sorted values. The packer is the same greedy first-fit, against the same
|
||||
* 56 KiB target, so identical state also fragments identically.
|
||||
*
|
||||
* Unknown fields are never decoded or re-encoded: base64url and hex are indistinguishable for
|
||||
* a string we don't know the meaning of, so an unknown field keeps its author's spelling.
|
||||
*/
|
||||
object ConcordListFragments {
|
||||
/** Pack target for one fragment's fully encoded event (the reference client's 56 KiB). */
|
||||
const val PACK_TARGET_BYTES = 57_344
|
||||
|
||||
/** The refusal line: no fragment event may exceed this many bytes, fully encoded. */
|
||||
const val EVENT_CEILING_BYTES = 65_536
|
||||
|
||||
/** Junk ceiling on a declared `frags`, so one bad fragment can't drive an unbounded scan. */
|
||||
const val MAX_DECLARED_FRAGS = 4096
|
||||
|
||||
/** Non-content event bytes (id, pubkey, sig, tags, scaffolding) — deliberately generous. */
|
||||
private const val EVENT_ENVELOPE_BYTES = 320
|
||||
|
||||
private val MATERIAL_KEYS = setOf("owner", "owner_salt", "community_root", "root_epoch", "control_pk", "control_root", "channels", "relays", "name")
|
||||
private val CHANNEL_KEYS = setOf("id", "key", "epoch", "name")
|
||||
private val ENTRY_KEYS = setOf("community_id", "seed", "current", "added_at")
|
||||
private val TOMBSTONE_KEYS = setOf("community_id", "removed_at")
|
||||
private val LIST_KEYS = setOf("frags", "entries", "tombstones")
|
||||
|
||||
private val json = Json { prettyPrint = false }
|
||||
|
||||
private val B64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
|
||||
private val HEX64 = Regex("^[0-9a-fA-F]{64}$")
|
||||
private val B64URL43 = Regex("^[A-Za-z0-9_-]{43}$")
|
||||
|
||||
// ---- encoding --------------------------------------------------------------------------
|
||||
|
||||
/** 32 bytes of hex to unpadded base64url; anything else passes through untouched. */
|
||||
fun hexToB64(value: String): String = if (HEX64.matches(value)) B64.encode(value.hexToByteArray()) else value
|
||||
|
||||
/** Unpadded base64url of 32 bytes back to lowercase hex; anything else passes through. */
|
||||
fun b64ToHex(value: String): String {
|
||||
if (!B64URL43.matches(value)) return value
|
||||
val bytes =
|
||||
try {
|
||||
B64.decode(value)
|
||||
} catch (_: Exception) {
|
||||
return value
|
||||
}
|
||||
return if (bytes.size == 32) bytes.toHexKey() else value
|
||||
}
|
||||
|
||||
// ---- canonical JSON --------------------------------------------------------------------
|
||||
|
||||
/** [element] with every object's keys sorted, recursively — a total order for tie-breaks. */
|
||||
fun canonical(element: JsonElement): JsonElement =
|
||||
when (element) {
|
||||
is JsonObject -> JsonObject(element.keys.sorted().associateWithTo(LinkedHashMap()) { canonical(element.getValue(it)) })
|
||||
is JsonArray -> JsonArray(element.map { canonical(it) })
|
||||
else -> element
|
||||
}
|
||||
|
||||
fun canonicalString(element: JsonElement): String = json.encodeToString(JsonElement.serializer(), canonical(element))
|
||||
|
||||
private fun str(element: JsonElement): String = json.encodeToString(JsonElement.serializer(), element)
|
||||
|
||||
/** Appends [source]'s keys outside [named] in sorted order, values canonicalized. */
|
||||
private fun MutableMap<String, JsonElement>.putExtras(
|
||||
source: JsonObject,
|
||||
named: Set<String>,
|
||||
) {
|
||||
for (k in source.keys.filter { it !in named }.sorted()) {
|
||||
val v = source.getValue(k)
|
||||
if (v is JsonNull) continue
|
||||
put(k, canonical(v))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- small typed readers (strict, like the reference parser) -------------------------------
|
||||
|
||||
private class FragmentParseException(
|
||||
message: String,
|
||||
) : IllegalArgumentException(message)
|
||||
|
||||
private fun obj(
|
||||
v: JsonElement?,
|
||||
what: String,
|
||||
): JsonObject = v as? JsonObject ?: throw FragmentParseException("$what is not an object")
|
||||
|
||||
private fun string(
|
||||
v: JsonElement?,
|
||||
what: String,
|
||||
): String {
|
||||
val p = v as? JsonPrimitive
|
||||
if (p == null || !p.isString) throw FragmentParseException("$what is not a string")
|
||||
return p.content
|
||||
}
|
||||
|
||||
private fun u64(
|
||||
v: JsonElement?,
|
||||
what: String,
|
||||
): Long {
|
||||
val p = v as? JsonPrimitive
|
||||
if (p == null || p.isString) throw FragmentParseException("$what is not an unsigned integer")
|
||||
val n = p.longOrNull ?: throw FragmentParseException("$what is not an unsigned integer")
|
||||
if (n < 0) throw FragmentParseException("$what is negative")
|
||||
return n
|
||||
}
|
||||
|
||||
private fun absent(v: JsonElement?) = v == null || v is JsonNull
|
||||
|
||||
private fun array(
|
||||
v: JsonElement?,
|
||||
what: String,
|
||||
): List<JsonElement> =
|
||||
when {
|
||||
v == null -> emptyList()
|
||||
v is JsonArray -> v
|
||||
else -> throw FragmentParseException("$what is not an array")
|
||||
}
|
||||
|
||||
// ---- wire -> internal ------------------------------------------------------------------
|
||||
|
||||
/** One decoded fragment: its declared `frags` and its content in the internal shape. */
|
||||
class DecodedFragment(
|
||||
val frags: Int,
|
||||
val doc: JsonObject,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses one fragment's decrypted plaintext into the internal shape. Throws on anything the
|
||||
* reference parser rejects; a caller treats that as "this index is missing", never as an
|
||||
* empty fragment.
|
||||
*/
|
||||
fun decodeFragment(plaintext: String): DecodedFragment {
|
||||
val root = obj(json.parseToJsonElement(plaintext), "fragment")
|
||||
val frags = u64(root["frags"], "frags")
|
||||
val entries = array(root["entries"], "entries").map { decodeEntry(it) }
|
||||
val tombstones = array(root["tombstones"], "tombstones").map { decodeTombstone(it) }
|
||||
val doc = LinkedHashMap<String, JsonElement>()
|
||||
for ((k, v) in root) if (k !in LIST_KEYS) doc[k] = v
|
||||
doc["entries"] = JsonArray(entries)
|
||||
doc["tombstones"] = JsonArray(tombstones)
|
||||
return DecodedFragment(frags.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), JsonObject(doc))
|
||||
}
|
||||
|
||||
private fun decodeEntry(v: JsonElement): JsonObject {
|
||||
val o = obj(v, "entry")
|
||||
val cid = b64ToHex(string(o["community_id"], "entry.community_id"))
|
||||
val current = decodeMaterial(o["current"], "entry.current", cid)
|
||||
val addedAt = u64(o["added_at"], "entry.added_at")
|
||||
val seed = if (absent(o["seed"])) current else decodeMaterial(o["seed"], "entry.seed", cid)
|
||||
val out = LinkedHashMap<String, JsonElement>()
|
||||
for ((k, value) in o) if (k !in ENTRY_KEYS) out[k] = value
|
||||
out["community_id"] = JsonPrimitive(cid)
|
||||
out["seed"] = seed
|
||||
out["current"] = current
|
||||
out["added_at"] = JsonPrimitive(addedAt)
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
private fun decodeMaterial(
|
||||
v: JsonElement?,
|
||||
what: String,
|
||||
communityId: String,
|
||||
): JsonObject {
|
||||
val o = obj(v, what)
|
||||
val out = LinkedHashMap<String, JsonElement>()
|
||||
for ((k, value) in o) if (k !in MATERIAL_KEYS && k != "community_id") out[k] = value
|
||||
out["community_id"] = JsonPrimitive(communityId)
|
||||
out["owner"] = JsonPrimitive(b64ToHex(string(o["owner"], "$what.owner")))
|
||||
out["owner_salt"] = JsonPrimitive(b64ToHex(string(o["owner_salt"], "$what.owner_salt")))
|
||||
out["community_root"] = JsonPrimitive(b64ToHex(string(o["community_root"], "$what.community_root")))
|
||||
out["root_epoch"] = JsonPrimitive(u64(o["root_epoch"], "$what.root_epoch"))
|
||||
if (!absent(o["control_pk"])) out["control_pk"] = JsonPrimitive(b64ToHex(string(o["control_pk"], "$what.control_pk")))
|
||||
if (!absent(o["control_root"])) out["control_root"] = JsonPrimitive(b64ToHex(string(o["control_root"], "$what.control_root")))
|
||||
out["channels"] = JsonArray(array(o["channels"], "$what.channels").map { decodeChannel(it) })
|
||||
out["relays"] = JsonArray(array(o["relays"], "$what.relays").map { JsonPrimitive(string(it, "$what.relays[]")) })
|
||||
out["name"] = JsonPrimitive(string(o["name"], "$what.name"))
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
private fun decodeChannel(v: JsonElement): JsonObject {
|
||||
val o = obj(v, "channel")
|
||||
val out = LinkedHashMap<String, JsonElement>()
|
||||
for ((k, value) in o) if (k !in CHANNEL_KEYS) out[k] = value
|
||||
out["id"] = JsonPrimitive(b64ToHex(string(o["id"], "channel.id")))
|
||||
if (!absent(o["key"])) out["key"] = JsonPrimitive(b64ToHex(string(o["key"], "channel.key")))
|
||||
out["epoch"] = JsonPrimitive(u64(o["epoch"], "channel.epoch"))
|
||||
out["name"] = JsonPrimitive(string(o["name"], "channel.name"))
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
private fun decodeTombstone(v: JsonElement): JsonObject {
|
||||
val o = obj(v, "tombstone")
|
||||
val out = LinkedHashMap<String, JsonElement>()
|
||||
for ((k, value) in o) if (k !in TOMBSTONE_KEYS) out[k] = value
|
||||
out["community_id"] = JsonPrimitive(b64ToHex(string(o["community_id"], "tombstone.community_id")))
|
||||
out["removed_at"] = JsonPrimitive(u64(o["removed_at"], "tombstone.removed_at"))
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
// ---- internal -> wire ------------------------------------------------------------------
|
||||
|
||||
private fun stringOr(
|
||||
v: JsonElement?,
|
||||
default: String,
|
||||
): String = (v as? JsonPrimitive)?.takeIf { it.isString }?.content ?: default
|
||||
|
||||
private fun number(v: JsonElement?): JsonPrimitive? = (v as? JsonPrimitive)?.takeIf { !it.isString && it.longOrNull != null }
|
||||
|
||||
/**
|
||||
* An internal snapshot in the wire shape: named 32-byte values as base64url, `community_id`
|
||||
* dropped (the entry carries it), empty `channels`/`relays` omitted. Throws on a snapshot
|
||||
* missing its keys rather than sealing a corrupt membership.
|
||||
*/
|
||||
private fun materialToWire(m: JsonObject): JsonObject {
|
||||
val owner = stringOr(m["owner"], "")
|
||||
val ownerSalt = stringOr(m["owner_salt"], "")
|
||||
val root = stringOr(m["community_root"], "")
|
||||
val epoch = number(m["root_epoch"])
|
||||
require(owner.isNotEmpty() && ownerSalt.isNotEmpty() && root.isNotEmpty() && epoch != null) {
|
||||
"malformed join material — refusing to serialize a corrupt snapshot"
|
||||
}
|
||||
val out = LinkedHashMap<String, JsonElement>()
|
||||
out["owner"] = JsonPrimitive(hexToB64(owner))
|
||||
out["owner_salt"] = JsonPrimitive(hexToB64(ownerSalt))
|
||||
out["community_root"] = JsonPrimitive(hexToB64(root))
|
||||
out["root_epoch"] = epoch
|
||||
(m["control_pk"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out["control_pk"] = JsonPrimitive(hexToB64(it.content)) }
|
||||
(m["control_root"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out["control_root"] = JsonPrimitive(hexToB64(it.content)) }
|
||||
val channels = (m["channels"] as? JsonArray)?.mapNotNull { (it as? JsonObject)?.let(::channelToWire) }.orEmpty()
|
||||
if (channels.isNotEmpty()) out["channels"] = JsonArray(channels)
|
||||
val relays = (m["relays"] as? JsonArray)?.filter { it is JsonPrimitive && it.isString }.orEmpty()
|
||||
if (relays.isNotEmpty()) out["relays"] = JsonArray(relays)
|
||||
out["name"] = JsonPrimitive(stringOr(m["name"], ""))
|
||||
out.putExtras(m, MATERIAL_KEYS + "community_id")
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
private fun channelToWire(c: JsonObject): JsonObject? {
|
||||
val id = stringOr(c["id"], "")
|
||||
val epoch = number(c["epoch"])
|
||||
if (id.isEmpty() || epoch == null) return null
|
||||
val out = LinkedHashMap<String, JsonElement>()
|
||||
out["id"] = JsonPrimitive(hexToB64(id))
|
||||
(c["key"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out["key"] = JsonPrimitive(hexToB64(it.content)) }
|
||||
out["epoch"] = epoch
|
||||
out["name"] = JsonPrimitive(stringOr(c["name"], ""))
|
||||
out.putExtras(c, CHANNEL_KEYS)
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
/**
|
||||
* `seed` with its cosmetic fields (`name`, `relays`, each channel's `name`) overwritten from
|
||||
* `current`: they are not the anchor's own, and leaving a stale label in place would fork the
|
||||
* two snapshots forever after one rename (CORD-02 §8).
|
||||
*/
|
||||
private fun withCurrentCosmetics(
|
||||
seed: JsonObject,
|
||||
current: JsonObject,
|
||||
): JsonObject {
|
||||
val currentNames =
|
||||
(current["channels"] as? JsonArray)
|
||||
.orEmpty()
|
||||
.mapNotNull { it as? JsonObject }
|
||||
.associate { stringOr(it["id"], "") to (it["name"] ?: JsonPrimitive("")) }
|
||||
// Rebuilt in declared order so the result serializes exactly like a fresh wire snapshot.
|
||||
val out = LinkedHashMap<String, JsonElement>()
|
||||
for (k in listOf("owner", "owner_salt", "community_root", "root_epoch", "control_pk", "control_root")) seed[k]?.let { out[k] = it }
|
||||
(seed["channels"] as? JsonArray)?.let { channels ->
|
||||
out["channels"] =
|
||||
JsonArray(
|
||||
channels.map { ch ->
|
||||
val o = ch as? JsonObject ?: return@map ch
|
||||
val name = currentNames[stringOr(o["id"], "")] ?: return@map o
|
||||
JsonObject(o.mapValuesTo(LinkedHashMap()) { (k, v) -> if (k == "name") name else v })
|
||||
},
|
||||
)
|
||||
}
|
||||
current["relays"]?.let { out["relays"] = it }
|
||||
out["name"] = current["name"] ?: JsonPrimitive("")
|
||||
// The seed's unknown keys, already in sorted order behind the named ones.
|
||||
for ((k, v) in seed) if (k !in MATERIAL_KEYS) out[k] = v
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
/**
|
||||
* [entryToWire], or null when the entry's join material is too incomplete to encode — only a
|
||||
* membership the typed reader already could not parse (kept verbatim from a retired 13302
|
||||
* document). It stays in that document; the fragments simply don't re-emit it, rather than one
|
||||
* unreadable legacy entry blocking every join and leave.
|
||||
*/
|
||||
private fun entryToWireOrNull(entry: JsonObject): JsonObject? =
|
||||
try {
|
||||
entryToWire(entry)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
|
||||
/** One internal entry in the wire shape, or throws on corrupt join material. */
|
||||
fun entryToWire(entry: JsonObject): JsonObject {
|
||||
val cid = stringOr(entry["community_id"], "")
|
||||
require(cid.isNotEmpty()) { "entry without a community_id" }
|
||||
val currentInternal = (entry["current"] as? JsonObject) ?: (entry["seed"] as? JsonObject) ?: throw IllegalArgumentException("entry without join material")
|
||||
val seedInternal = (entry["seed"] as? JsonObject) ?: currentInternal
|
||||
val current = materialToWire(currentInternal)
|
||||
val seed = withCurrentCosmetics(materialToWire(seedInternal), current)
|
||||
val out = LinkedHashMap<String, JsonElement>()
|
||||
out["community_id"] = JsonPrimitive(hexToB64(cid))
|
||||
if (str(seed) != str(current)) out["seed"] = seed
|
||||
out["current"] = current
|
||||
out["added_at"] = number(entry["added_at"]) ?: JsonPrimitive(0L)
|
||||
out.putExtras(entry, ENTRY_KEYS)
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
fun tombstoneToWire(tombstone: JsonObject): JsonObject? {
|
||||
val cid = stringOr(tombstone["community_id"], "")
|
||||
if (cid.isEmpty()) return null
|
||||
val out = LinkedHashMap<String, JsonElement>()
|
||||
out["community_id"] = JsonPrimitive(hexToB64(cid))
|
||||
out["removed_at"] = number(tombstone["removed_at"]) ?: JsonPrimitive(0L)
|
||||
out.putExtras(tombstone, TOMBSTONE_KEYS)
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
/** A fragment's exact plaintext: what NIP-44 seals and relays store. */
|
||||
fun serializeFragment(
|
||||
frags: Int,
|
||||
entries: List<JsonObject>,
|
||||
tombstones: List<JsonObject>,
|
||||
extras: JsonObject = JsonObject(emptyMap()),
|
||||
): String {
|
||||
val out = LinkedHashMap<String, JsonElement>()
|
||||
out["frags"] = JsonPrimitive(frags)
|
||||
if (entries.isNotEmpty()) out["entries"] = JsonArray(entries)
|
||||
if (tombstones.isNotEmpty()) out["tombstones"] = JsonArray(tombstones)
|
||||
out.putExtras(extras, LIST_KEYS)
|
||||
return str(JsonObject(out))
|
||||
}
|
||||
|
||||
/** The zero-element List an emptied index republishes (CORD-02 §8). */
|
||||
fun emptyFragment(frags: Int): String = serializeFragment(frags, emptyList(), emptyList())
|
||||
|
||||
// ---- sizing ----------------------------------------------------------------------------
|
||||
|
||||
/** NIP-44 v2 padded plaintext length. */
|
||||
fun nip44PaddedLen(unpadded: Int): Int {
|
||||
if (unpadded <= 32) return 32
|
||||
val nextPower = 1 shl (32 - (unpadded - 1).countLeadingZeroBits())
|
||||
val chunk = if (nextPower <= 256) 32 else nextPower / 8
|
||||
return chunk * ((unpadded - 1) / chunk + 1)
|
||||
}
|
||||
|
||||
/** The encoded event a plaintext of [plaintextBytes] becomes (NIP-44 v2 payload, base64). */
|
||||
fun projectedEventBytes(plaintextBytes: Int): Int {
|
||||
val raw = 1 + 32 + 2 + nip44PaddedLen(plaintextBytes) + 32
|
||||
return (raw + 2) / 3 * 4 + EVENT_ENVELOPE_BYTES
|
||||
}
|
||||
|
||||
private fun byteLen(s: String) = s.encodeToByteArray().size
|
||||
|
||||
// ---- merge (internal shape) ------------------------------------------------------------
|
||||
|
||||
private fun epochOf(m: JsonObject?): Long = number(m?.get("root_epoch"))?.longOrNull ?: 0L
|
||||
|
||||
/** Higher epoch wins; a tie goes to the lexicographically lowest canonical bytes. */
|
||||
private fun freshest(
|
||||
a: JsonObject,
|
||||
b: JsonObject,
|
||||
): JsonObject {
|
||||
val ea = epochOf(a)
|
||||
val eb = epochOf(b)
|
||||
if (ea != eb) return if (ea > eb) a else b
|
||||
return if (canonicalString(a) <= canonicalString(b)) a else b
|
||||
}
|
||||
|
||||
/** Lower epoch wins; a tie goes to the lowest canonical bytes. */
|
||||
private fun earliest(
|
||||
a: JsonObject,
|
||||
b: JsonObject,
|
||||
): JsonObject {
|
||||
val ea = epochOf(a)
|
||||
val eb = epochOf(b)
|
||||
if (ea != eb) return if (ea < eb) a else b
|
||||
return if (canonicalString(a) <= canonicalString(b)) a else b
|
||||
}
|
||||
|
||||
private fun currentOf(e: JsonObject): JsonObject? = (e["current"] as? JsonObject) ?: (e["seed"] as? JsonObject)
|
||||
|
||||
private fun seedOf(e: JsonObject): JsonObject? = (e["seed"] as? JsonObject) ?: (e["current"] as? JsonObject)
|
||||
|
||||
private fun mergeEntry(
|
||||
x: JsonObject,
|
||||
y: JsonObject,
|
||||
): JsonObject {
|
||||
val cx = currentOf(x)
|
||||
val cy = currentOf(y)
|
||||
val current = if (cx != null && cy != null) freshest(cx, cy) else cx ?: cy
|
||||
val sx = seedOf(x)
|
||||
val sy = seedOf(y)
|
||||
val seed = if (sx != null && sy != null) earliest(sx, sy) else sx ?: sy
|
||||
val addedAt = maxOf(number(x["added_at"])?.longOrNull ?: 0L, number(y["added_at"])?.longOrNull ?: 0L)
|
||||
val merged = LinkedHashMap<String, JsonElement>(x)
|
||||
merged.putAll(y)
|
||||
merged["community_id"] = x.getValue("community_id")
|
||||
if (seed != null) merged["seed"] = seed
|
||||
if (current != null) merged["current"] = current
|
||||
merged["added_at"] = JsonPrimitive(addedAt)
|
||||
// An exclusion marker only means something while it is beyond the held epoch.
|
||||
val excluded = listOfNotNull(number(x["excluded_at_epoch"])?.longOrNull, number(y["excluded_at_epoch"])?.longOrNull).maxOrNull()
|
||||
if (excluded != null && excluded > epochOf(current)) merged["excluded_at_epoch"] = JsonPrimitive(excluded) else merged.remove("excluded_at_epoch")
|
||||
return JsonObject(merged)
|
||||
}
|
||||
|
||||
private fun idOf(o: JsonObject): String? = stringOr(o["community_id"], "").ifEmpty { null }
|
||||
|
||||
/**
|
||||
* Merges two internal documents: one membership per `community_id` (current keeps the higher
|
||||
* epoch, seed the lower, `added_at` the later), one tombstone per id (the later `removed_at`),
|
||||
* other keys with [b] winning. Commutative and idempotent on everything but those unknown
|
||||
* document keys, which is what lets fragments and devices merge in any order.
|
||||
*/
|
||||
fun mergeDocs(
|
||||
a: JsonObject,
|
||||
b: JsonObject,
|
||||
): JsonObject {
|
||||
val entries = LinkedHashMap<String, JsonObject>()
|
||||
for (e in listOf(a, b).flatMap { (it["entries"] as? JsonArray).orEmpty() }) {
|
||||
val o = e as? JsonObject ?: continue
|
||||
val id = idOf(o) ?: continue
|
||||
entries[id] = entries[id]?.let { mergeEntry(it, o) } ?: o
|
||||
}
|
||||
val tombstones = LinkedHashMap<String, JsonObject>()
|
||||
for (t in listOf(a, b).flatMap { (it["tombstones"] as? JsonArray).orEmpty() }) {
|
||||
val o = t as? JsonObject ?: continue
|
||||
val id = idOf(o) ?: continue
|
||||
val prev = tombstones[id]
|
||||
if (prev == null || (number(o["removed_at"])?.longOrNull ?: 0L) > (number(prev["removed_at"])?.longOrNull ?: 0L)) tombstones[id] = o
|
||||
}
|
||||
val out = LinkedHashMap<String, JsonElement>()
|
||||
for ((k, v) in a) if (k != "entries" && k != "tombstones") out[k] = v
|
||||
for ((k, v) in b) if (k != "entries" && k != "tombstones") out[k] = v
|
||||
out["entries"] = JsonArray(entries.keys.sorted().map { entries.getValue(it) })
|
||||
out["tombstones"] = JsonArray(tombstones.keys.sorted().map { tombstones.getValue(it) })
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
/** Per community id, the latest `removed_at` in [doc]. */
|
||||
fun removals(doc: JsonObject): Map<String, Long> {
|
||||
val out = HashMap<String, Long>()
|
||||
for (t in (doc["tombstones"] as? JsonArray).orEmpty()) {
|
||||
val o = t as? JsonObject ?: continue
|
||||
val id = idOf(o) ?: continue
|
||||
val at = number(o["removed_at"])?.longOrNull ?: 0L
|
||||
if (at > (out[id] ?: Long.MIN_VALUE)) out[id] = at
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A membership is live only while its entry outranks its removal (CORD-02 §8). */
|
||||
fun isLive(
|
||||
entry: JsonObject,
|
||||
removals: Map<String, Long>,
|
||||
): Boolean {
|
||||
val removedAt = removals[idOf(entry) ?: return false] ?: return true
|
||||
return (number(entry["added_at"])?.longOrNull ?: 0L) > removedAt
|
||||
}
|
||||
|
||||
// ---- packing ---------------------------------------------------------------------------
|
||||
|
||||
private class Packing(
|
||||
val entries: MutableList<JsonObject> = ArrayList(),
|
||||
val tombstones: MutableList<JsonObject> = ArrayList(),
|
||||
val extras: JsonObject = JsonObject(emptyMap()),
|
||||
) {
|
||||
fun serialize(frags: Int) = serializeFragment(frags, entries, tombstones, extras)
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an internal document into fragment plaintexts, greedily (first fragment with room)
|
||||
* under [PACK_TARGET_BYTES]. Entries a tombstone outranks are dropped — the tombstone alone
|
||||
* carries the state — and document-level unknown keys ride on fragment 0.
|
||||
*/
|
||||
fun pack(doc: JsonObject): List<String> {
|
||||
val removals = removals(doc)
|
||||
val entries =
|
||||
(doc["entries"] as? JsonArray)
|
||||
.orEmpty()
|
||||
.mapNotNull { it as? JsonObject }
|
||||
.filter { isLive(it, removals) }
|
||||
.sortedBy { idOf(it) }
|
||||
.mapNotNull { entryToWireOrNull(it) }
|
||||
val tombstones =
|
||||
(doc["tombstones"] as? JsonArray)
|
||||
.orEmpty()
|
||||
.mapNotNull { it as? JsonObject }
|
||||
.sortedBy { idOf(it) }
|
||||
.mapNotNull { tombstoneToWire(it) }
|
||||
val docExtras = JsonObject(doc.filterKeys { it != "entries" && it != "tombstones" && it !in LIST_KEYS })
|
||||
|
||||
val frags = mutableListOf(Packing(extras = docExtras))
|
||||
|
||||
fun fits(
|
||||
f: Packing,
|
||||
add: Int,
|
||||
) = projectedEventBytes(byteLen(f.serialize(1)) + add) <= PACK_TARGET_BYTES
|
||||
|
||||
for (e in entries) {
|
||||
val cost = byteLen(str(e)) + 1
|
||||
val last = frags.last()
|
||||
if (last.entries.isEmpty() || fits(last, cost)) last.entries.add(e) else frags.add(Packing(entries = mutableListOf(e)))
|
||||
}
|
||||
// A fragment's first tombstone is placed without a size check, exactly as the reference
|
||||
// packer does: identical state must fragment identically across clients, and the overshoot
|
||||
// is one tombstone (~80 bytes) against an 8 KiB margin under the ceiling.
|
||||
for (t in tombstones) {
|
||||
val cost = byteLen(str(t)) + 1
|
||||
val last = frags.last()
|
||||
if (last.tombstones.isEmpty() || fits(last, cost)) last.tombstones.add(t) else frags.add(Packing(tombstones = mutableListOf(t)))
|
||||
}
|
||||
val total = frags.size
|
||||
return frags.map { it.serialize(total) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-serializes one held fragment ([fragmentDoc], internal shape) after replacing its
|
||||
* memberships and removals for [ids] with [merged]'s, keeping its declared [frags]. This is
|
||||
* the scoped write (CORD-02 §8): it touches only the fragment holding the changed membership,
|
||||
* so a fragment stranded on an unreachable relay never blocks a join or a leave.
|
||||
*/
|
||||
fun rewriteFragment(
|
||||
fragmentDoc: JsonObject,
|
||||
merged: JsonObject,
|
||||
ids: Set<String>,
|
||||
frags: Int,
|
||||
): String {
|
||||
fun pick(
|
||||
doc: JsonObject,
|
||||
key: String,
|
||||
) = (doc[key] as? JsonArray).orEmpty().mapNotNull { it as? JsonObject }
|
||||
|
||||
val keptEntries = pick(fragmentDoc, "entries").filter { idOf(it) !in ids } + pick(merged, "entries").filter { idOf(it) in ids }
|
||||
val keptTombs = pick(fragmentDoc, "tombstones").filter { idOf(it) !in ids } + pick(merged, "tombstones").filter { idOf(it) in ids }
|
||||
val doc =
|
||||
JsonObject(
|
||||
fragmentDoc.filterKeys { it != "entries" && it != "tombstones" } +
|
||||
mapOf("entries" to JsonArray(keptEntries), "tombstones" to JsonArray(keptTombs)),
|
||||
)
|
||||
val removals = removals(doc)
|
||||
return serializeFragment(
|
||||
frags,
|
||||
keptEntries.filter { isLive(it, removals) }.sortedBy { idOf(it) }.mapNotNull { entryToWireOrNull(it) },
|
||||
keptTombs.sortedBy { idOf(it) }.mapNotNull { tombstoneToWire(it) },
|
||||
JsonObject(doc.filterKeys { it != "entries" && it != "tombstones" && it !in LIST_KEYS }),
|
||||
)
|
||||
}
|
||||
|
||||
/** The community ids a document mentions, in entries or tombstones. */
|
||||
fun idsIn(doc: JsonObject): Set<String> =
|
||||
((doc["entries"] as? JsonArray).orEmpty() + (doc["tombstones"] as? JsonArray).orEmpty())
|
||||
.mapNotNullTo(HashSet()) { (it as? JsonObject)?.let(::idOf) }
|
||||
}
|
||||
+133
-19
@@ -22,11 +22,15 @@ package com.vitorpamplona.quartz.concord.cord03Channels
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionAlgo
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionNonce
|
||||
@@ -66,12 +70,13 @@ object ChannelChat {
|
||||
text: String,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
ms: Int = MsTag.remainderFor(createdAt),
|
||||
): Event =
|
||||
RumorAssembler.assembleRumor(
|
||||
authorPubKey,
|
||||
ChatEvent.build(text, createdAt) {
|
||||
channelBinding(channelId, epoch)
|
||||
extraTags.forEach { addUnique(it) }
|
||||
channelBinding(channelId, epoch, ms)
|
||||
withoutBinding(extraTags).forEach { add(it) }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -82,6 +87,10 @@ object ChannelChat {
|
||||
* into a minichat), an inline quote stays in the main chat timeline — the two
|
||||
* reply modes the composer offers. Matches Armada, where a kind-9 `q` is an
|
||||
* inline quote deliberately kept out of threads.
|
||||
*
|
||||
* The `q` tag is the four-element NIP-C7 form `["q", <rumor id>, "", <author>]` the spec's
|
||||
* examples (§2.1) and Armada write: an empty relay hint (a rumor lives on no relay) and the
|
||||
* quoted author, so a reader can render the card before the quoted rumor arrives.
|
||||
*/
|
||||
fun inlineReply(
|
||||
authorPubKey: HexKey,
|
||||
@@ -92,6 +101,7 @@ object ChannelChat {
|
||||
parentAuthor: HexKey,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
ms: Int = MsTag.remainderFor(createdAt),
|
||||
): Event =
|
||||
message(
|
||||
authorPubKey = authorPubKey,
|
||||
@@ -99,7 +109,8 @@ object ChannelChat {
|
||||
epoch = epoch,
|
||||
text = text,
|
||||
createdAt = createdAt,
|
||||
extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)) + extraTags,
|
||||
extraTags = arrayOf(arrayOf("q", parentId, "", parentAuthor), arrayOf("p", parentAuthor)) + extraTags,
|
||||
ms = ms,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -124,20 +135,48 @@ object ChannelChat {
|
||||
newText: String,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
ms: Int = MsTag.remainderFor(createdAt),
|
||||
): Event =
|
||||
RumorAssembler.assembleRumor<ConcordChatEditEvent>(
|
||||
pubKey = authorPubKey,
|
||||
createdAt = createdAt,
|
||||
kind = ConcordChatEditEvent.KIND,
|
||||
tags =
|
||||
arrayOf(
|
||||
ChannelTag.assemble(channelId),
|
||||
EpochTag.assemble(epoch),
|
||||
arrayOf("e", targetId),
|
||||
) + extraTags,
|
||||
tags = bindingTags(channelId, epoch, ms) + arrayOf(arrayOf("e", targetId)) + withoutBinding(extraTags),
|
||||
content = newText,
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an unsigned kind-5 **delete** rumor (CORD-01 Deletions, examples §2.4) retracting
|
||||
* the author's own [targets] inside the channel, bound to [channelId]/[epoch].
|
||||
*
|
||||
* NIP-09 shape: one `["e", <rumor id>]` per target, then one `["k", <kind>]` per distinct
|
||||
* target kind (`9` for a message, `1111` for a thread reply, `7` for a reaction), and the
|
||||
* optional [reason] as content. It names *rumor* ids relays never saw, so it must be wrapped
|
||||
* on the channel plane like any other Chat rumor — never published as a signed kind 5 or a
|
||||
* NIP-17 DM, both of which would leak the rumor ids outside the community. Receivers honor it
|
||||
* only for targets the delete's own author wrote. A delete never expires (CORD-08).
|
||||
*/
|
||||
fun delete(
|
||||
authorPubKey: HexKey,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
targets: List<Event>,
|
||||
createdAt: Long,
|
||||
reason: String = "",
|
||||
ms: Int = MsTag.remainderFor(createdAt),
|
||||
): Event {
|
||||
require(targets.isNotEmpty()) { "A delete must name at least one target" }
|
||||
val eTags = targets.map { arrayOf("e", it.id) }
|
||||
val kTags = targets.map { it.kind }.distinct().map { arrayOf("k", it.toString()) }
|
||||
return RumorAssembler.assembleRumor<DeletionRequestEvent>(
|
||||
pubKey = authorPubKey,
|
||||
createdAt = createdAt,
|
||||
kind = DeletionRequestEvent.KIND,
|
||||
tags = bindingTags(channelId, epoch, ms) + eTags.toTypedArray() + kTags.toTypedArray(),
|
||||
content = reason,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an unsigned kind-1111 **thread reply** ([CommentEvent], NIP-22) to
|
||||
* [parent], bound to [channelId]/[epoch].
|
||||
@@ -160,12 +199,13 @@ object ChannelChat {
|
||||
parent: Event,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
ms: Int = MsTag.remainderFor(createdAt),
|
||||
): Event =
|
||||
RumorAssembler.assembleRumor(
|
||||
authorPubKey,
|
||||
CommentEvent.replyBuilder(text, EventHintBundle(parent), createdAt) {
|
||||
channelBinding(channelId, epoch)
|
||||
extraTags.forEach { add(it) }
|
||||
channelBinding(channelId, epoch, ms)
|
||||
withoutBinding(extraTags).forEach { add(it) }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -186,6 +226,7 @@ object ChannelChat {
|
||||
parent: Event,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
ms: Int = MsTag.remainderFor(createdAt),
|
||||
): Event {
|
||||
val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) }
|
||||
val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n")
|
||||
@@ -197,6 +238,7 @@ object ChannelChat {
|
||||
parent = parent,
|
||||
createdAt = createdAt,
|
||||
extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags,
|
||||
ms = ms,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -218,19 +260,19 @@ object ChannelChat {
|
||||
content: String,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
ms: Int = MsTag.remainderFor(createdAt),
|
||||
): Event =
|
||||
RumorAssembler.assembleRumor<ReactionEvent>(
|
||||
pubKey = authorPubKey,
|
||||
createdAt = createdAt,
|
||||
kind = ReactionEvent.KIND,
|
||||
tags =
|
||||
arrayOf(
|
||||
ChannelTag.assemble(channelId),
|
||||
EpochTag.assemble(epoch),
|
||||
arrayOf("e", targetId),
|
||||
arrayOf("p", targetAuthor),
|
||||
arrayOf("k", targetKind.toString()),
|
||||
) + extraTags,
|
||||
bindingTags(channelId, epoch, ms) +
|
||||
arrayOf(
|
||||
arrayOf("e", targetId),
|
||||
arrayOf("p", targetAuthor),
|
||||
arrayOf("k", targetKind.toString()),
|
||||
) + withoutBinding(extraTags),
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -250,6 +292,7 @@ object ChannelChat {
|
||||
imetas: List<IMetaTag>,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
ms: Int = MsTag.remainderFor(createdAt),
|
||||
): Event {
|
||||
val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) }
|
||||
val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n")
|
||||
@@ -260,6 +303,7 @@ object ChannelChat {
|
||||
text = finalText,
|
||||
createdAt = createdAt,
|
||||
extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags,
|
||||
ms = ms,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -337,12 +381,13 @@ object ChannelChat {
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
createdAt: Long,
|
||||
ms: Int = MsTag.remainderFor(createdAt),
|
||||
): Event =
|
||||
RumorAssembler.assembleRumor<Event>(
|
||||
pubKey = authorPubKey,
|
||||
createdAt = createdAt,
|
||||
kind = KIND_TYPING,
|
||||
tags = arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch)),
|
||||
tags = bindingTags(channelId, epoch, ms),
|
||||
content = "",
|
||||
)
|
||||
|
||||
@@ -364,6 +409,75 @@ object ChannelChat {
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
): Boolean = rumor.tags.isConcordBoundTo(channelId, epoch)
|
||||
|
||||
/** Timer notice (CORD-08 §4), a Chat Plane kind. */
|
||||
const val KIND_TIMER_NOTICE = 1740
|
||||
|
||||
/**
|
||||
* Every rumor kind a Chat Plane may carry into the app (CORD-02 Appendix B): messages,
|
||||
* thread replies, reactions, deletes, edits, the typing heartbeat and the CORD-08 timer
|
||||
* notice. Chat ingest refuses anything else — above all the other planes' kinds (a Control
|
||||
* edition 3308, a Guestbook 3306/3309/3312, a rekey 3303, a direct invite 3313): the planes
|
||||
* share one store, so without this a channel key-holder could inject a rumor another reader
|
||||
* would take for a Control edition (the reference client's `PLANE_KINDS` refusal).
|
||||
*/
|
||||
val CHAT_KINDS: Set<Int> =
|
||||
setOf(
|
||||
ChatEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
DeletionRequestEvent.KIND,
|
||||
ConcordChatEditEvent.KIND,
|
||||
KIND_TYPING,
|
||||
KIND_TIMER_NOTICE,
|
||||
)
|
||||
|
||||
/** True when [kind] may ride a Chat Plane ([CHAT_KINDS]). */
|
||||
fun isChatKind(kind: Int): Boolean = kind in CHAT_KINDS
|
||||
|
||||
/**
|
||||
* The Chat Plane ingest gate for a wrap already opened under [channelId]'s key at [epoch]:
|
||||
* returns its rumor only when every Chat rule holds, else null (drop it).
|
||||
* - the seal is the encrypted kind 20013 (CORD-02 §5: a plaintext 20014 seal is Control-only);
|
||||
* - the rumor kind is a Chat kind ([CHAT_KINDS]), never another plane's;
|
||||
* - the binding is strict: exactly one `channel` and one `epoch`, equal to the plane's
|
||||
* ([isBoundTo], CORD-03 §3);
|
||||
* - its `ms` tag, if any, is well formed (CORD-02 §4/§5 — a malformed one is dropped, never
|
||||
* interpreted).
|
||||
*/
|
||||
fun acceptOpened(
|
||||
opened: OpenedStreamEvent,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
): Event? {
|
||||
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null
|
||||
val rumor = opened.rumor
|
||||
if (!isChatKind(rumor.kind)) return null
|
||||
if (!isBoundTo(rumor, channelId, epoch)) return null
|
||||
if (orderingMs(rumor) == null) return null
|
||||
return rumor
|
||||
}
|
||||
|
||||
/**
|
||||
* The rumor's CORD-02 §4 ordering time, `createdAt * 1000 + ms`, or null when its `ms` tag is
|
||||
* malformed or duplicated (such a rumor is dropped, never interpreted). See [MsTag].
|
||||
*/
|
||||
fun orderingMs(rumor: Event): Long? = MsTag.orderingMs(rumor.createdAt, rumor.tags)
|
||||
|
||||
/** The binding every Chat rumor commits, in the examples' order: channel, epoch, ms. */
|
||||
private fun bindingTags(
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
ms: Int,
|
||||
): Array<Array<String>> = arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch), MsTag.assemble(ms))
|
||||
|
||||
private val BINDING_TAG_NAMES = setOf(ChannelTag.TAG_NAME, EpochTag.TAG_NAME, MsTag.TAG_NAME)
|
||||
|
||||
/**
|
||||
* [extraTags] minus any binding tag: a caller's extra `channel`/`epoch`/`ms` would make the
|
||||
* binding ambiguous, and strict receivers (ours included) drop a duplicated binding.
|
||||
*/
|
||||
private fun withoutBinding(extraTags: Array<Array<String>>): Array<Array<String>> = extraTags.filterNot { it.isNotEmpty() && it[0] in BINDING_TAG_NAMES }.toTypedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-10
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.quartz.concord.cord03Channels
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent
|
||||
@@ -65,16 +66,7 @@ class ConcordChatEditEvent(
|
||||
* remainder tag (CORD-02 §4). Used to order competing edits at sub-second precision, matching the
|
||||
* reference client (an absent/malformed `ms` tag reads as 0). "Latest edit wins" compares this.
|
||||
*/
|
||||
fun orderingMs(): Long {
|
||||
val remainder =
|
||||
tags
|
||||
.firstOrNull { it.size > 1 && it[0] == "ms" }
|
||||
?.get(1)
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it in 0..999 }
|
||||
?: 0
|
||||
return createdAt * 1000 + remainder
|
||||
}
|
||||
fun orderingMs(): Long = MsTag.orderingMs(createdAt, tags) ?: (createdAt * 1000)
|
||||
|
||||
companion object {
|
||||
const val KIND = 3302
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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.cord03Channels
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Disappearing Messages (CORD-08): one Community-wide timer, `message_expiration` in the metadata
|
||||
* entity, after which every Chat-plane message expires.
|
||||
*
|
||||
* - **Sender (§2):** while the timer is set, every durable Chat rumor carries
|
||||
* `["expiration", created_at + timer]` inside the signed rumor, and its kind-1059 wrap carries the
|
||||
* same tag so NIP-40 relays delete the ciphertext. Deletes (5) and timer notices (1740) never
|
||||
* carry it — an expiring delete would resurrect what it erased, an expiring notice would erase
|
||||
* why history is missing — and ephemeral kinds (typing 23311, voice presence 23313) carry nothing.
|
||||
* - **Reader (§3):** only the rumor's own tag counts (the wrap's is relay hygiene). An expired rumor
|
||||
* is refused at ingest, never displayed, and purged from local storage.
|
||||
* - **Notice (§4):** kind 1740 with `["timer", "<secs>"]` and the channel binding, shown only when
|
||||
* its author holds MANAGE_METADATA.
|
||||
*
|
||||
* A timer change is never retroactive: a rumor keeps the expiry it was sent under.
|
||||
*/
|
||||
object ConcordDisappearing {
|
||||
/** The timer-notice kind, shared with NIP-17 disappearing DMs (§4). */
|
||||
const val KIND_TIMER_NOTICE = ChannelChat.KIND_TIMER_NOTICE
|
||||
|
||||
const val TIMER_TAG = "timer"
|
||||
|
||||
private const val KIND_DELETE = 5
|
||||
private const val KIND_TYPING = 23311
|
||||
private const val KIND_VOICE_PRESENCE = 23313
|
||||
|
||||
private val DECIMAL = Regex("^(0|[1-9][0-9]*)$")
|
||||
|
||||
/** True when a rumor of [kind] must carry the expiration tag while the timer is set (§2). */
|
||||
fun expires(kind: Int): Boolean = kind != KIND_DELETE && kind != KIND_TIMER_NOTICE && kind != KIND_TYPING && kind != KIND_VOICE_PRESENCE
|
||||
|
||||
/**
|
||||
* The expiration a rumor created at [createdAt] of [kind] must carry under [timerSecs], or null
|
||||
* when the timer is off or the kind is exempt.
|
||||
*/
|
||||
fun expirationFor(
|
||||
kind: Int,
|
||||
createdAt: Long,
|
||||
timerSecs: Long?,
|
||||
): Long? {
|
||||
if (timerSecs == null || timerSecs < 1 || !expires(kind)) return null
|
||||
return createdAt + timerSecs
|
||||
}
|
||||
|
||||
/**
|
||||
* [tags] with the expiration tag applied: added (or replaced) when [expiration] is set, left as is
|
||||
* otherwise. The rumor's copy is what readers enforce, so it must be in the signed tags.
|
||||
*/
|
||||
fun withExpiration(
|
||||
tags: TagArray,
|
||||
expiration: Long?,
|
||||
): TagArray {
|
||||
if (expiration == null) return tags
|
||||
return tags.filterNot { it.isNotEmpty() && it[0] == ExpirationTag.TAG_NAME }.toTypedArray() + ExpirationTag.assemble(expiration)
|
||||
}
|
||||
|
||||
/** The rumor's own expiration, the only one a reader judges by (§3). */
|
||||
fun expirationOf(rumor: Event): Long? = rumor.tags.firstNotNullOfOrNull(ExpirationTag::parse)
|
||||
|
||||
/** True when [rumor] carries an expiration at or before [now] (NIP-40: `exp <= now`). */
|
||||
fun isExpired(
|
||||
rumor: Event,
|
||||
now: Long = TimeUtils.now(),
|
||||
): Boolean {
|
||||
val exp = expirationOf(rumor) ?: return false
|
||||
return exp <= now
|
||||
}
|
||||
|
||||
/** The timer-notice rumor: the new value in seconds (`0` = off) bound to [channelId] at [epoch]. */
|
||||
fun timerNotice(
|
||||
authorPubKey: HexKey,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
timerSecs: Long,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Event =
|
||||
RumorAssembler.assembleRumor(
|
||||
authorPubKey,
|
||||
eventTemplate<Event>(KIND_TIMER_NOTICE, "", createdAt) {
|
||||
channelBinding(channelId, epoch)
|
||||
add(arrayOf(TIMER_TAG, timerSecs.coerceAtLeast(0).toString()))
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* The timer a notice announces, in seconds (`0` = turned off), or null when [rumor] isn't a
|
||||
* well-formed notice — a malformed value is dropped, never guessed at.
|
||||
*/
|
||||
fun noticeTimerSecs(rumor: Event): Long? {
|
||||
if (rumor.kind != KIND_TIMER_NOTICE) return null
|
||||
val values = rumor.tags.filter { it.size >= 2 && it[0] == TIMER_TAG }
|
||||
if (values.size != 1) return null
|
||||
val raw = values[0][1]
|
||||
if (!DECIMAL.matches(raw)) return null
|
||||
return raw.toLongOrNull()
|
||||
}
|
||||
}
|
||||
+15
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord03Channels
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
@@ -36,6 +37,9 @@ fun <T : Event> TagArrayBuilder<T>.channel(channelId: HexKey) = addUnique(Channe
|
||||
|
||||
fun <T : Event> TagArrayBuilder<T>.epoch(epoch: Long) = addUnique(EpochTag.assemble(epoch))
|
||||
|
||||
/** The CORD-02 §4 sub-second remainder (0..999) of the rumor's send time. */
|
||||
fun <T : Event> TagArrayBuilder<T>.ms(ms: Int) = addUnique(MsTag.assemble(ms))
|
||||
|
||||
/** Binds an event to [channelId] at [epoch] — both tags every Chat Plane rumor carries. */
|
||||
fun <T : Event> TagArrayBuilder<T>.channelBinding(
|
||||
channelId: HexKey,
|
||||
@@ -44,3 +48,14 @@ fun <T : Event> TagArrayBuilder<T>.channelBinding(
|
||||
channel(channelId)
|
||||
epoch(epoch)
|
||||
}
|
||||
|
||||
/** [channelBinding] plus the `["ms", …]` remainder every Chat rumor carries (CORD-02 §4). */
|
||||
fun <T : Event> TagArrayBuilder<T>.channelBinding(
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
ms: Int,
|
||||
) = apply {
|
||||
channel(channelId)
|
||||
epoch(epoch)
|
||||
ms(ms)
|
||||
}
|
||||
|
||||
+29
-7
@@ -25,17 +25,39 @@ import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
|
||||
/** The channel id this Chat Plane rumor is bound to, or null if unbound. */
|
||||
fun TagArray.concordChannel(): HexKey? = firstNotNullOfOrNull(ChannelTag::parse)
|
||||
/**
|
||||
* The value of the one tag named [name], or null when it is absent **or appears more than once**
|
||||
* (a binding must be unambiguous — the reference client's `uniqueTag`). Every tag whose name
|
||||
* matches counts toward the duplicate check, even one too short to carry a value.
|
||||
*/
|
||||
private fun TagArray.uniqueTagValue(name: String): String? {
|
||||
var found: String? = null
|
||||
var count = 0
|
||||
for (tag in this) {
|
||||
if (tag.isEmpty() || tag[0] != name) continue
|
||||
count++
|
||||
if (count > 1) return null
|
||||
found = tag.getOrNull(1)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** The epoch this Chat Plane rumor is bound to, or null if unbound/malformed. */
|
||||
fun TagArray.concordEpoch(): Long? = firstNotNullOfOrNull(EpochTag::parse)
|
||||
/** The channel id this Chat Plane rumor is bound to, or null if unbound or ambiguous (duplicated). */
|
||||
fun TagArray.concordChannel(): HexKey? = uniqueTagValue(ChannelTag.TAG_NAME)?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* True when these tags bind to exactly [channelId] and [epoch]. Recipients must
|
||||
* reject any Chat Plane event whose binding does not match the plane it arrived on.
|
||||
* The epoch this Chat Plane rumor is bound to, or null if unbound, ambiguous (duplicated), or not
|
||||
* in canonical decimal form ([EpochTag.parse]).
|
||||
*/
|
||||
fun TagArray.concordEpoch(): Long? = uniqueTagValue(EpochTag.TAG_NAME)?.let { EpochTag.parse(arrayOf(EpochTag.TAG_NAME, it)) }
|
||||
|
||||
/**
|
||||
* True when these tags bind to exactly [channelId] and [epoch] (CORD-03 §3): exactly one
|
||||
* `channel` tag strict-equal to [channelId], and exactly one `epoch` tag strict-equal to the
|
||||
* canonical decimal of [epoch] (`"04"` or `"+4"` never match 4). Recipients must reject any Chat
|
||||
* Plane event whose binding does not match the plane it arrived on.
|
||||
*/
|
||||
fun TagArray.isConcordBoundTo(
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
): Boolean = concordChannel() == channelId && concordEpoch() == epoch
|
||||
): Boolean = uniqueTagValue(ChannelTag.TAG_NAME) == channelId && uniqueTagValue(EpochTag.TAG_NAME) == epoch.toString()
|
||||
|
||||
+9
-1
@@ -35,11 +35,19 @@ class EpochTag {
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
/**
|
||||
* The epoch, or null when the value is not its canonical decimal form (CORD-01 Encoding:
|
||||
* "no leading zeros"). `"04"`, `"+4"`, `"-1"` and `" 4"` are all refused: the binding is a
|
||||
* strict string comparison, so a spelling that merely parses to the same number is a
|
||||
* different binding.
|
||||
*/
|
||||
fun parse(tag: Array<String>): Long? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toLongOrNull()
|
||||
val epoch = tag[1].toLongOrNull() ?: return null
|
||||
ensure(epoch >= 0 && epoch.toString() == tag[1]) { return null }
|
||||
return epoch
|
||||
}
|
||||
|
||||
fun assemble(epoch: Long) = arrayOf(TAG_NAME, epoch.toString())
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.cord03Channels.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* The `["ms", "<0..999>"]` sub-second remainder every Concord Chat rumor carries (CORD-02 §4):
|
||||
* `created_at` stays whole unix seconds, untweaked (CORD-01), and the true send time is
|
||||
* `created_at * 1000 + ms`. Every comparison the protocol makes (message order, edit recency)
|
||||
* uses that basis.
|
||||
*
|
||||
* Parsing is strict decimal, like the reference client's `resolveMs`: `"0"` or `"1"`…`"999"`
|
||||
* with no leading zero, sign, whitespace or exponent. A tag outside that shape is malformed, and
|
||||
* a malformed rumor is dropped rather than interpreted (CORD-02 §5), so the excess can never
|
||||
* smuggle ordering the author's clock did not produce.
|
||||
*/
|
||||
class MsTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "ms"
|
||||
|
||||
private val CANONICAL = Regex("^(0|[1-9][0-9]{0,2})$")
|
||||
|
||||
/** The remainder in [tag], or null when it is not an `ms` tag or is malformed. */
|
||||
fun parse(tag: Array<String>): Int? {
|
||||
if (tag.isEmpty() || tag[0] != TAG_NAME) return null
|
||||
val raw = tag.getOrNull(1) ?: return null
|
||||
if (!CANONICAL.matches(raw)) return null
|
||||
return raw.toInt()
|
||||
}
|
||||
|
||||
fun assemble(ms: Int): Array<String> {
|
||||
require(ms in 0..999) { "ms remainder must be in 0..999, was $ms" }
|
||||
return arrayOf(TAG_NAME, ms.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* The sub-second remainder of "now" when [createdAt] is the current second, else 0. A
|
||||
* builder handed the current `TimeUtils.now()` thus stamps the real millisecond; one
|
||||
* handed any other second (a fixed test time, a backdated rumor, a rollover between the
|
||||
* two clock reads) stamps 0, which is still a well-formed, ordering-safe tag.
|
||||
*/
|
||||
fun remainderFor(createdAt: Long): Int {
|
||||
val nowMs = TimeUtils.nowMillis()
|
||||
return if (nowMs / 1000 == createdAt) (nowMs % 1000).toInt() else 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The rumor's ordering basis `createdAt * 1000 + ms` (CORD-02 §4). A missing tag counts
|
||||
* as 0; a malformed or duplicated one yields null, and the caller drops the rumor.
|
||||
*/
|
||||
fun orderingMs(
|
||||
createdAt: Long,
|
||||
tags: TagArray,
|
||||
): Long? {
|
||||
var found: Int? = null
|
||||
var count = 0
|
||||
for (tag in tags) {
|
||||
if (tag.isEmpty() || tag[0] != TAG_NAME) continue
|
||||
count++
|
||||
found = parse(tag) ?: return null
|
||||
}
|
||||
if (count > 1) return null
|
||||
return createdAt * 1000 + (found ?: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.HexKey
|
||||
|
||||
/**
|
||||
* The `vac` authority citation (CORD-04 §1/§5) for writers and for verifiers outside the
|
||||
* Control Plane fold — Kicks, rekey rotations (kind 3303), and anything else that acts under
|
||||
* a Grant.
|
||||
*
|
||||
* Every non-owner authority action cites the exact Grant edition its actor holds their rank
|
||||
* under — their own `grant_locator(community_id, actor)` coordinate, at the version and edition
|
||||
* hash of the head the roster folded. A reader drops (parks) the action until it holds that
|
||||
* Grant at or past the cited version, then judges the actor's rank against its **current**
|
||||
* roster, so a stale citation grandfathers nothing. The owner is proven by the `community_id`
|
||||
* and cites nothing. Mirrors Armada `citationSatisfied` (control.ts).
|
||||
*/
|
||||
object AuthorityCitations {
|
||||
/**
|
||||
* The citation [actor] must attach to an authority action, resolved from the community's
|
||||
* folded [authority] roster; null for the owner, and for an actor holding no honored Grant
|
||||
* (no reader would honor their action anyway).
|
||||
*/
|
||||
fun forActor(
|
||||
authority: AuthorityResolver,
|
||||
actor: HexKey,
|
||||
): AuthorityCitation? = authority.citationFor(actor)
|
||||
|
||||
/**
|
||||
* [forActor] straight from the Control Plane [editions]: folds the roster of the community
|
||||
* [communityId], owned by [ownerPubKey], and cites [actor]'s Grant head in it. Short-circuits
|
||||
* for the owner without folding.
|
||||
*/
|
||||
fun forActor(
|
||||
editions: Collection<ControlEdition>,
|
||||
communityId: ByteArray,
|
||||
ownerPubKey: HexKey,
|
||||
actor: HexKey,
|
||||
): AuthorityCitation? {
|
||||
if (actor.equals(ownerPubKey, ignoreCase = true)) return null
|
||||
return AuthorityResolver.resolve(editions, communityId, ownerPubKey).citationFor(actor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [citation] satisfies CORD-04 §5's sync floor for an action by [actor] against the
|
||||
* folded [authority] roster: the owner needs none; anyone else must cite their own Grant
|
||||
* coordinate, held at or past the cited version (hash matching at equality). Completeness
|
||||
* only — the caller still checks the actor's bit and rank against [authority].
|
||||
*/
|
||||
fun isSatisfied(
|
||||
authority: AuthorityResolver,
|
||||
actor: HexKey,
|
||||
citation: AuthorityCitation?,
|
||||
): Boolean = authority.citationSatisfied(actor, citation)
|
||||
}
|
||||
+238
-65
@@ -20,6 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
@@ -30,28 +33,48 @@ import com.vitorpamplona.quartz.utils.Log
|
||||
* "The Roster is owner-rooted: every Grant and Role is signed by an npub the
|
||||
* Roster ranks strictly above it, and the chain terminates at the owner."
|
||||
*
|
||||
* Build one with [resolve] from the current entity heads (the values of
|
||||
* [EditionFold.fold]) plus the community's known owner pubkey. It then answers:
|
||||
* Build one with [resolve] from the community's Control Plane editions, its
|
||||
* `community_id` (which pins every derived coordinate) and its known owner pubkey.
|
||||
* It then answers:
|
||||
* - [rank] — a member's authority (lower is higher; owner is [OWNER_RANK]; a
|
||||
* member with no validly-granted role has no rank).
|
||||
* - [effectivePermissions] — the union of a member's roles' bits (owner: all).
|
||||
* - [isBanned] — membership in the healed banlist union.
|
||||
* - [isBanned] — membership in the folded Banlist head.
|
||||
* - [canActOn] — whether an actor may take a permissioned action on a target:
|
||||
* the actor must hold the bit, must strictly outrank the target (equal cannot
|
||||
* act on equal), and the owner is unremovable.
|
||||
* - [citationFor] / [citationSatisfied] — the `vac` authority citation (CORD-04 §5)
|
||||
* an actor writes, and whether an edition's citation resolves against this roster.
|
||||
*
|
||||
* Grants are validated by a fixpoint that only ever empowers members reachable
|
||||
* from the owner: a Grant is honored when its signer already outranks every
|
||||
* assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never
|
||||
* touch the owner can never bootstrap themselves.
|
||||
* from the owner: a Grant is honored when it sits at its member's own coordinate,
|
||||
* its signer already outranks every assigned Role, holds
|
||||
* [ConcordPermissions.MANAGE_ROLES], and cites the Grant it acts under. Cycles that
|
||||
* never touch the owner can never bootstrap themselves.
|
||||
*/
|
||||
@ConsistentCopyVisibility
|
||||
data class AuthorityResolver private constructor(
|
||||
private val communityIdHex: String,
|
||||
private val ownerLower: String,
|
||||
private val roles: Map<String, RoleEntity>,
|
||||
private val memberRoles: Map<String, Set<String>>,
|
||||
private val banned: Set<String>,
|
||||
/** Each role-holder's honored Grant head, keyed by member — what a `vac` must pin. */
|
||||
private val grantHeads: Map<String, GrantHead>,
|
||||
) {
|
||||
// Derived from the constructor values, so deliberately outside the data class's equality.
|
||||
private val communityId: ByteArray by lazy { communityIdHex.hexToByteArray() }
|
||||
private val banlistEidHex: String by lazy { banlistCoordinateHex(communityId) }
|
||||
|
||||
/**
|
||||
* A member's honored Grant head: the edition every reader folded their roles from, and so
|
||||
* the one their authority actions cite (CORD-04 §5).
|
||||
*/
|
||||
data class GrantHead(
|
||||
val version: Long,
|
||||
val hashHex: String,
|
||||
)
|
||||
|
||||
/** The resolved role definitions (authority-gated), keyed by role id. Safe for display. */
|
||||
fun roles(): Map<String, RoleEntity> = roles
|
||||
|
||||
@@ -73,7 +96,7 @@ data class AuthorityResolver private constructor(
|
||||
*/
|
||||
fun roleHolders(): Set<String> = memberRoles.keys
|
||||
|
||||
/** The healed banlist union (lowercase hex). */
|
||||
/** The folded Banlist (lowercase hex). */
|
||||
fun bannedMembers(): Set<String> = banned
|
||||
|
||||
/** The member's rank, lower being higher authority; null = no authority. Owner = [OWNER_RANK]. */
|
||||
@@ -84,6 +107,12 @@ data class AuthorityResolver private constructor(
|
||||
return held.mapNotNull { roles[it]?.position }.minOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* [author]'s standing for an equal-version tie-break (CORD-04 §1, "authority first"):
|
||||
* the owner first, then by Role position, a roleless author last.
|
||||
*/
|
||||
fun tieBreakRank(author: String): Long = rank(author) ?: Long.MAX_VALUE
|
||||
|
||||
/** The union of a member's roles' permission bits (owner holds every bit). */
|
||||
fun effectivePermissions(pubKey: String): ConcordPermissions {
|
||||
val m = pubKey.lowercase()
|
||||
@@ -132,6 +161,68 @@ data class AuthorityResolver private constructor(
|
||||
return actorRank < targetRank
|
||||
}
|
||||
|
||||
/** [member]'s honored Grant head, or null when they hold none (the owner never does). */
|
||||
fun grantHead(member: String): GrantHead? = grantHeads[member.lowercase()]
|
||||
|
||||
/**
|
||||
* The `vac` citation (CORD-04 §1/§5) [actor] must attach to a Control-authority action:
|
||||
* their own Grant coordinate, pinned at the version and hash of the head this roster
|
||||
* folded. Null for the owner (who cites nothing) and for an actor holding no honored
|
||||
* Grant (whose actions no reader would honor anyway).
|
||||
*/
|
||||
fun citationFor(actor: String): AuthorityCitation? {
|
||||
val m = actor.lowercase()
|
||||
if (m == ownerLower) return null
|
||||
val head = grantHeads[m] ?: return null
|
||||
val coordinate = grantCoordinateOrNull(communityId, m) ?: return null
|
||||
return AuthorityCitation(coordinate.hexToByteArray(), head.version, head.hashHex.hexToByteArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [citation] satisfies CORD-04 §5 for an action by [actor] against this roster:
|
||||
* the owner needs none; anyone else must cite their **own** Grant coordinate, and this
|
||||
* roster must hold that Grant at the cited version with the cited hash, or past it. A
|
||||
* citation ahead of what we hold (not yet synced), at a forked hash, or naming someone
|
||||
* else's Grant parks the action — here, drops it until a later fold. It is a sync floor,
|
||||
* never the verdict: the caller still judges rank against the current roster.
|
||||
*/
|
||||
fun citationSatisfied(
|
||||
actor: String,
|
||||
citation: AuthorityCitation?,
|
||||
): Boolean {
|
||||
val m = actor.lowercase()
|
||||
if (m == ownerLower) return true
|
||||
val coordinate = grantCoordinateOrNull(communityId, m) ?: return false
|
||||
return citationMatches(citation, coordinate, grantHeads[m])
|
||||
}
|
||||
|
||||
/** [citationSatisfied] for [edition]'s author and `vac`. */
|
||||
fun citationSatisfied(edition: ControlEdition): Boolean = citationSatisfied(edition.author, edition.authorityCitation)
|
||||
|
||||
/**
|
||||
* Whether an edition's content may stand at its coordinate at all, independent of who
|
||||
* signed it: the entity coordinates CORD-04 §1 derives from the `community_id` (Metadata
|
||||
* at the `community_id`, a Grant at its member's `grant_locator`, the Banlist at
|
||||
* `banlist_locator`, an Invite Registry at its author's locator) and the content caps
|
||||
* (CORD-04 §2, CORD-02 §6). A sub-kind we do not model is judged by its signer alone.
|
||||
*/
|
||||
fun isWellFormed(edition: ControlEdition): Boolean = wellFormed(edition, communityId, communityIdHex, banlistEidHex)
|
||||
|
||||
/**
|
||||
* Whether a reader honors [edition] as an action gated by [bit] (CORD-04 §5): it is
|
||||
* [isWellFormed], its author is the owner or holds [bit] (and is not banned), and its
|
||||
* `vac` resolves ([citationSatisfied]). A null [bit] is owner-only.
|
||||
*/
|
||||
fun admits(
|
||||
edition: ControlEdition,
|
||||
bit: Int?,
|
||||
): Boolean {
|
||||
if (!isWellFormed(edition)) return false
|
||||
if (isOwner(edition.author)) return true
|
||||
if (bit == null || !hasPermission(edition.author, bit)) return false
|
||||
return citationSatisfied(edition)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConcordAuthorityResolver"
|
||||
|
||||
@@ -146,11 +237,74 @@ data class AuthorityResolver private constructor(
|
||||
*/
|
||||
private const val MAX_BAN_RESOLUTION_PASSES = 4
|
||||
|
||||
/** `grant_locator(community_id, member)` as hex, or null when [member] is not a 32-byte hex key. */
|
||||
internal fun grantCoordinateOrNull(
|
||||
communityId: ByteArray,
|
||||
member: String,
|
||||
): String? {
|
||||
val xOnly = member.hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null
|
||||
return ConcordKeyDerivation.grantCoordinate(communityId, xOnly).toHexKey()
|
||||
}
|
||||
|
||||
private fun banlistCoordinateHex(communityId: ByteArray): String = ConcordKeyDerivation.banlistCoordinate(communityId).toHexKey()
|
||||
|
||||
/**
|
||||
* The CORD-04 §5 citation test against the Grant head [head] a verifier holds at the
|
||||
* actor's coordinate [expectedGrantIdHex] — Armada's `citationSatisfied`, case for case:
|
||||
* the citation must name that coordinate, and the head must be past the cited version,
|
||||
* or at it with the same hash. Behind it (unsynced) or at a forked hash, it parks.
|
||||
*/
|
||||
internal fun citationMatches(
|
||||
citation: AuthorityCitation?,
|
||||
expectedGrantIdHex: String,
|
||||
head: GrantHead?,
|
||||
): Boolean {
|
||||
if (citation == null || head == null) return false
|
||||
if (citation.grantId.toHexKey() != expectedGrantIdHex) return false
|
||||
if (head.version > citation.grantVersion) return true
|
||||
if (head.version == citation.grantVersion) return head.hashHex == citation.grantHash.toHexKey()
|
||||
return false
|
||||
}
|
||||
|
||||
/** The Grant content at [edition], or null when it does not sit at its member's own coordinate (S5). */
|
||||
private fun grantAt(
|
||||
edition: ControlEdition,
|
||||
communityId: ByteArray,
|
||||
): GrantEntity? {
|
||||
val g = ConcordJson.decodeOrNull<GrantEntity>(edition.content) ?: return null
|
||||
val coordinate = grantCoordinateOrNull(communityId, g.member.lowercase()) ?: return null
|
||||
return g.takeIf { coordinate == edition.entityIdHex }
|
||||
}
|
||||
|
||||
/** See [isWellFormed]. */
|
||||
private fun wellFormed(
|
||||
edition: ControlEdition,
|
||||
communityId: ByteArray,
|
||||
communityIdHex: String,
|
||||
banlistEidHex: String,
|
||||
): Boolean =
|
||||
when (edition.entityKind) {
|
||||
ControlEntityKind.METADATA ->
|
||||
edition.entityIdHex == communityIdHex &&
|
||||
ConcordJson.decodeOrNull<MetadataEntity>(edition.content)?.let(ConcordLimits::metadataFits) == true
|
||||
ControlEntityKind.ROLE -> ConcordJson.decodeOrNull<RoleEntity>(edition.content)?.isWellFormedAt(edition.entityIdHex) == true
|
||||
ControlEntityKind.GRANT -> grantAt(edition, communityId) != null
|
||||
ControlEntityKind.BANLIST -> edition.entityIdHex == banlistEidHex && ConcordJson.decodeBanlist(edition.content) != null
|
||||
ControlEntityKind.INVITE_REGISTRY ->
|
||||
edition.entityIdHex == ConcordKeyDerivation.inviteLinksCoordinate(communityId, edition.author.hexToByteArray()).toHexKey()
|
||||
else -> true
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner-rooted authority state of a community, with the banlist honored **against the
|
||||
* Control Plane itself** (CORD-04 §4: a reader "drops every event from a banned npub —
|
||||
* message, reaction, edit, or authority action").
|
||||
*
|
||||
* [communityId] pins every derived coordinate: a Grant is honored only at its member's own
|
||||
* `grant_locator(community_id, member)` and the Banlist only at `banlist_locator(community_id)`
|
||||
* (CORD-02 A.6), so a second chain minted at any other coordinate cannot override the
|
||||
* canonical one.
|
||||
*
|
||||
* This is a bounded two-pass, because the rule is circular as stated: you cannot know who is
|
||||
* banned until you fold the Banlist, and you cannot decide who may write the Banlist without
|
||||
* knowing who is banned. `docs/concord-banlist-rank-conformance.md` §4 row 3 flagged that to
|
||||
@@ -178,9 +332,10 @@ data class AuthorityResolver private constructor(
|
||||
*/
|
||||
fun resolve(
|
||||
editions: Collection<ControlEdition>,
|
||||
communityId: ByteArray,
|
||||
ownerPubKey: String,
|
||||
): AuthorityResolver {
|
||||
val passA = resolveOnce(editions, ownerPubKey, bannedAuthors = emptySet())
|
||||
val passA = resolveOnce(editions, communityId, ownerPubKey, bannedAuthors = emptySet())
|
||||
// A further pass costs a whole fold, so skip it unless it could change something. Nobody
|
||||
// banned, or nobody banned who ever wrote to the Control Plane — the overwhelmingly common
|
||||
// shape, since most bans land on plain members who hold no role and author no editions —
|
||||
@@ -204,7 +359,7 @@ data class AuthorityResolver private constructor(
|
||||
var mask = passA.banned
|
||||
var result = passA
|
||||
repeat(MAX_BAN_RESOLUTION_PASSES) {
|
||||
result = resolveOnce(editions, ownerPubKey, bannedAuthors = mask)
|
||||
result = resolveOnce(editions, communityId, ownerPubKey, bannedAuthors = mask)
|
||||
if (result.banned == mask) return result
|
||||
mask = result.banned
|
||||
}
|
||||
@@ -226,10 +381,17 @@ data class AuthorityResolver private constructor(
|
||||
*/
|
||||
private fun resolveOnce(
|
||||
editions: Collection<ControlEdition>,
|
||||
communityId: ByteArray,
|
||||
ownerPubKey: String,
|
||||
bannedAuthors: Set<String>,
|
||||
): AuthorityResolver {
|
||||
val ownerLower = ownerPubKey.lowercase()
|
||||
val communityIdHex = communityId.toHexKey()
|
||||
|
||||
// grant_locator(community_id, member) for every author the gates ask about, derived once.
|
||||
val grantCoordinates = HashMap<String, String?>()
|
||||
|
||||
fun grantCoordinateOf(member: String): String? = grantCoordinates.getOrPut(member) { grantCoordinateOrNull(communityId, member) }
|
||||
|
||||
// Chains grouped by entity: one role chain per role id, one grant chain per member
|
||||
// coordinate. We fold each chain through AUTHORIZED editions only, so a rogue cannot
|
||||
@@ -240,6 +402,7 @@ data class AuthorityResolver private constructor(
|
||||
|
||||
var roles: Map<String, RoleEntity> = emptyMap()
|
||||
var memberRoles: Map<String, Set<String>> = emptyMap()
|
||||
var grantHeads: Map<String, GrantHead> = emptyMap()
|
||||
|
||||
// Authority helpers read the CURRENT (previous-pass) roster, so within a pass a granter's
|
||||
// rank is judged by the chain already settled behind it — the owner-rooted resolution the
|
||||
@@ -250,6 +413,9 @@ data class AuthorityResolver private constructor(
|
||||
return held.mapNotNull { roles[it]?.position }.minOrNull()
|
||||
}
|
||||
|
||||
// Authority first at an equal-version tie (CORD-04 §1), judged by the same settled roster.
|
||||
fun tieRank(author: String): Long = rankOf(author.lowercase()) ?: Long.MAX_VALUE
|
||||
|
||||
// The bits a member currently holds, evaluated against the chain settled so far — the same
|
||||
// owner-rooted basis as rankOf. Needed inside the fixpoint; effectivePermissionsOf below is
|
||||
// the post-settlement view.
|
||||
@@ -267,6 +433,17 @@ data class AuthorityResolver private constructor(
|
||||
return held.any { roles[it]?.permissionBits()?.has(ConcordPermissions.MANAGE_ROLES) == true }
|
||||
}
|
||||
|
||||
// CORD-04 §5: a non-owner edition must cite the exact Grant it acts under — its author's
|
||||
// own coordinate — and we must hold that Grant at or past the cited version, hash
|
||||
// matching at equality. Judged against the Grant heads settled so far, so the owner's
|
||||
// grants settle first and delegation bootstraps outward (Armada `citedOk`).
|
||||
fun cited(e: ControlEdition): Boolean {
|
||||
val author = e.author.lowercase()
|
||||
if (author == ownerLower) return true
|
||||
val coordinate = grantCoordinateOf(author) ?: return false
|
||||
return citationMatches(e.authorityCitation, coordinate, grantHeads[author])
|
||||
}
|
||||
|
||||
// Owner-rooted fixpoint: each pass only ever empowers members reachable from the owner, so
|
||||
// the roster grows monotonically and settles. Bounded by the edition count as a backstop.
|
||||
val maxPasses = editions.size + 1
|
||||
@@ -279,12 +456,16 @@ data class AuthorityResolver private constructor(
|
||||
entity: String,
|
||||
e: ControlEdition,
|
||||
): Boolean {
|
||||
// Well-formed first, for every author: a role_id naming another coordinate, an
|
||||
// over-long name, or a live role at the owner's position 0 is no role at all.
|
||||
val r = ConcordJson.decodeOrNull<RoleEntity>(e.content) ?: return false
|
||||
if (!r.isWellFormedAt(entity)) return false
|
||||
val author = e.author.lowercase()
|
||||
if (author == ownerLower) return true
|
||||
if (author in bannedAuthors) return false
|
||||
if (!holdsManageRoles(author)) return false
|
||||
if (!cited(e)) return false
|
||||
val authorRank = rankOf(author) ?: return false
|
||||
val r = ConcordJson.decodeOrNull<RoleEntity>(e.content) ?: return false
|
||||
// MANAGE_ROLES alone was the whole test, which let any holder rewrite the
|
||||
// role they hold — position 1 with every bit — and then demote the real
|
||||
// admins beneath them. Grants are gated on rank (a granter must outrank
|
||||
@@ -302,7 +483,7 @@ data class AuthorityResolver private constructor(
|
||||
|
||||
val newRoles = HashMap<String, RoleEntity>()
|
||||
for ((entity, chain) in roleChains) {
|
||||
val head = EditionFold.foldEntityGated(chain) { roleGate(entity, it) } ?: continue
|
||||
val head = EditionFold.foldEntityGated(chain, rank = ::tieRank) { roleGate(entity, it) } ?: continue
|
||||
val r = ConcordJson.decodeOrNull<RoleEntity>(head.content) ?: continue
|
||||
if (r.deleted || r.position < 1) continue // no role may claim the owner's position 0
|
||||
newRoles[entity] = r
|
||||
@@ -312,14 +493,18 @@ data class AuthorityResolver private constructor(
|
||||
// AND strictly outranks every role it hands out. Same candidate-then-gate shape, so a
|
||||
// rogue grant is dropped without orphaning the honest grants chained above it.
|
||||
fun grantGate(e: ControlEdition): Boolean {
|
||||
// The coordinate must be the member's own grant_locator (CORD-04 §1, S5): a chain
|
||||
// minted anywhere else is not that member's Grant, whoever signed it.
|
||||
val g = grantAt(e, communityId) ?: return false
|
||||
val granter = e.author.lowercase()
|
||||
if (granter == ownerLower) return true
|
||||
if (granter in bannedAuthors) return false
|
||||
if (!holdsManageRoles(granter)) return false
|
||||
if (!cited(e)) return false
|
||||
val granterRank = rankOf(granter) ?: return false
|
||||
val g = ConcordJson.decodeOrNull<GrantEntity>(e.content) ?: return false
|
||||
// Must strictly outrank each assigned role that actually exists...
|
||||
if (!g.roleIds.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true }) return false
|
||||
val assigned = g.roleIds.take(ConcordLimits.MAX_ROLES_PER_MEMBER)
|
||||
if (!assigned.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true }) return false
|
||||
// ...and outrank the member being edited. A grant is an action ON that
|
||||
// member, and a REVOKE carries no role ids at all — `all {}` over an
|
||||
// empty list is vacuously true, so without this any MANAGE_ROLES holder
|
||||
@@ -330,15 +515,36 @@ data class AuthorityResolver private constructor(
|
||||
}
|
||||
|
||||
val newMemberRoles = HashMap<String, Set<String>>()
|
||||
val newGrantHeads = HashMap<String, GrantHead>()
|
||||
for ((_, chain) in grantChains) {
|
||||
val head = EditionFold.foldEntityGated(chain, gate = ::grantGate) ?: continue
|
||||
val g = ConcordJson.decodeOrNull<GrantEntity>(head.content) ?: continue
|
||||
newMemberRoles[g.member.lowercase()] = g.roleIds.filter { newRoles.containsKey(it) }.toSet()
|
||||
val head = EditionFold.foldEntityGated(chain, rank = ::tieRank, gate = ::grantGate) ?: continue
|
||||
val g = grantAt(head, communityId) ?: continue
|
||||
val member = g.member.lowercase()
|
||||
// A member holds at most 64 Roles (CORD-04 §2): the rest of the list is ignored.
|
||||
newMemberRoles[member] =
|
||||
g.roleIds
|
||||
.take(ConcordLimits.MAX_ROLES_PER_MEMBER)
|
||||
.filter { newRoles.containsKey(it) }
|
||||
.toSet()
|
||||
newGrantHeads[member] = GrantHead(head.version, head.hashHex)
|
||||
}
|
||||
|
||||
if (newRoles == roles && newMemberRoles == memberRoles) break
|
||||
if (newRoles == roles && newMemberRoles == memberRoles && newGrantHeads == grantHeads) break
|
||||
roles = newRoles
|
||||
memberRoles = newMemberRoles
|
||||
grantHeads = newGrantHeads
|
||||
}
|
||||
|
||||
// A Community carries at most 100 Roles (CORD-04 §2): fold the 100 lowest role_ids and
|
||||
// ignore the rest, after authorization, exactly where Armada trims them.
|
||||
if (roles.size > ConcordLimits.MAX_ROLES_PER_COMMUNITY) {
|
||||
val kept =
|
||||
roles.keys
|
||||
.sorted()
|
||||
.take(ConcordLimits.MAX_ROLES_PER_COMMUNITY)
|
||||
.toSet()
|
||||
roles = roles.filterKeys { it in kept }
|
||||
memberRoles = memberRoles.mapValues { (_, held) -> held.filterTo(HashSet()) { it in kept } }
|
||||
}
|
||||
|
||||
// The union of a member's roles' permission bits (owner holds every bit).
|
||||
@@ -350,22 +556,22 @@ data class AuthorityResolver private constructor(
|
||||
return acc
|
||||
}
|
||||
|
||||
// Banlist: honored only from a signer holding BAN (or the owner). The banlist is a single
|
||||
// replaced doc, so fold its chain to the head first — that honors a legitimate unban, which
|
||||
// is a *chained* edition replacing the previous set (e.g. ban→unban). Then heal concurrent
|
||||
// forks: two moderators who ban different abusers at the same chain version fork the doc, and
|
||||
// folding to one head would silently drop the other's ban. Union in every authorized edition
|
||||
// that is NOT an ancestor of the head — those are the parallel bans the chain never absorbed.
|
||||
// Ancestors (superseded by the chain, including an unban's now-cleared target) are already
|
||||
// reflected by the head and must not be resurrected. This is CORD-06's "down-only healing":
|
||||
// a concurrent ban is never lost, while an on-chain unban still takes effect.
|
||||
val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST }
|
||||
// Banlist: honored only from a signer holding BAN (or the owner), at the one coordinate
|
||||
// CORD-02 A.6 derives for it. It is a single replaced document folded to ONE head like any
|
||||
// entity (CORD-04 §4): two admins banning different members at the same version collide,
|
||||
// the fold keeps one edition (authority first, then the lower rumor id), and the loser's
|
||||
// addition drops until its writer re-heals it on top of the winner. Unioning every fork
|
||||
// instead made a ban on a losing fork impossible to lift — no later edition supersedes a
|
||||
// fork — and diverged from every other client's fold.
|
||||
val banlistEid = banlistCoordinateHex(communityId)
|
||||
val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST && it.entityIdHex == banlistEid }
|
||||
|
||||
fun banGate(e: ControlEdition): Boolean {
|
||||
if (ConcordJson.decodeBanlist(e.content) == null) return false
|
||||
val author = e.author.lowercase()
|
||||
return author == ownerLower || (author !in bannedAuthors && effectivePermissionsOf(author).has(ConcordPermissions.BAN))
|
||||
if (author == ownerLower) return true
|
||||
return author !in bannedAuthors && effectivePermissionsOf(author).has(ConcordPermissions.BAN) && cited(e)
|
||||
}
|
||||
val authorizedBanlist = allBanlist.filter(::banGate)
|
||||
|
||||
// CORD-04 §3's rank rule binds "every action", and it names banning as its example ("an
|
||||
// admin cannot ban a peer admin"); §5 step 3 restates it. Only §4, which defines the
|
||||
@@ -428,46 +634,13 @@ data class AuthorityResolver private constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
val banned = HashSet<String>()
|
||||
// Candidate-then-gate, like roles and grants: an unauthorized banlist edition in the
|
||||
// middle of the chain must not orphan the authorized ones chained above it (which, on
|
||||
// a banlist, would silently resurrect every ban a later unban had cleared).
|
||||
val banHead = EditionFold.foldEntityGated(allBanlist, gate = ::banGate)
|
||||
if (banHead != null) {
|
||||
banned.addAll(effectiveList(banHead, HashSet()))
|
||||
// Ancestry is a STRUCTURAL fact, so it is walked over the full pool: an unauthorized
|
||||
// edition on the head's back-chain still supersedes what is beneath it, and walking
|
||||
// only the authorized subset would stop there and mis-read those genuine ancestors as
|
||||
// concurrent forks — un-doing the unban the chain already recorded.
|
||||
val ancestry = banlistAncestry(banHead, allBanlist)
|
||||
for (edition in authorizedBanlist) {
|
||||
if (edition.hashHex !in ancestry) {
|
||||
banned.addAll(effectiveList(edition, HashSet()))
|
||||
}
|
||||
}
|
||||
}
|
||||
val banHead = EditionFold.foldEntityGated(allBanlist, rank = ::tieRank, gate = ::banGate)
|
||||
val banned = banHead?.let { effectiveList(it, HashSet()) } ?: emptySet()
|
||||
|
||||
return AuthorityResolver(ownerLower, roles, memberRoles.toMap(), banned)
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of edition hashes on [head]'s back-chain (head itself plus every edition it chains
|
||||
* from via `prevHash`), among [pool]. Used to tell a superseded ancestor (already reflected by
|
||||
* the head) from a concurrent fork (a parallel ban to heal). The `add`-guarded walk also
|
||||
* terminates on any cycle.
|
||||
*/
|
||||
private fun banlistAncestry(
|
||||
head: ControlEdition,
|
||||
pool: List<ControlEdition>,
|
||||
): Set<String> {
|
||||
val byHash = pool.associateBy { it.hashHex }
|
||||
val acc = HashSet<String>()
|
||||
var cur: ControlEdition? = head
|
||||
while (cur != null && acc.add(cur.hashHex)) {
|
||||
val prev = cur.prevHash?.toHexKey()
|
||||
cur = if (prev != null) byHash[prev] else null
|
||||
}
|
||||
return acc
|
||||
return AuthorityResolver(communityIdHex, ownerLower, roles, memberRoles.toMap(), banned, grantHeads)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* The protocol's size caps on Control Plane entities (CORD-04 §2, CORD-02 §6), counted as
|
||||
* UTF-8 bytes. They are **read-side rules as well as write-side ones**: a writer refuses to
|
||||
* mint an edition past them, and the fold drops (or, for the role counts, trims) whatever
|
||||
* another client minted past them, exactly as the reference client (Armada `roles.ts` /
|
||||
* `control.ts`) does, so both converge on the same state.
|
||||
*/
|
||||
object ConcordLimits {
|
||||
/** The protocol-wide name cap: Roles, Channels and the Community name (CORD-02 §6). */
|
||||
const val NAME_MAX_BYTES = 64
|
||||
|
||||
/** The Community description cap (CORD-02 §6). */
|
||||
const val DESCRIPTION_MAX_BYTES = 10_000
|
||||
|
||||
/** A member holds at most this many Roles; a Grant's extra `role_ids` are ignored (CORD-04 §2). */
|
||||
const val MAX_ROLES_PER_MEMBER = 64
|
||||
|
||||
/** A Community folds at most this many Roles: the lowest `role_id`s win (CORD-04 §2). */
|
||||
const val MAX_ROLES_PER_COMMUNITY = 100
|
||||
|
||||
fun utf8Size(s: String): Int = s.encodeToByteArray().size
|
||||
|
||||
fun nameFits(name: String): Boolean = utf8Size(name) <= NAME_MAX_BYTES
|
||||
|
||||
fun descriptionFits(description: String?): Boolean = description == null || utf8Size(description) <= DESCRIPTION_MAX_BYTES
|
||||
|
||||
/** Whether [metadata] is within the Community metadata caps (CORD-02 §6). */
|
||||
fun metadataFits(metadata: MetadataEntity): Boolean = nameFits(metadata.name) && descriptionFits(metadata.description)
|
||||
}
|
||||
+59
-6
@@ -23,10 +23,15 @@ package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.eid
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.ev
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.CanonicalDecimal
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EidTag
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EpTag
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EvTag
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag
|
||||
import com.vitorpamplona.quartz.concord.crypto.EditionHash
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
|
||||
@@ -51,9 +56,15 @@ class AuthorityCitation(
|
||||
* verbatim entity [content]. Its identity is [hash] — a domain-separated hash of
|
||||
* exactly those fields (see [EditionHash]) — which the next edition cites in `ep`,
|
||||
* forming an unforgeable chain.
|
||||
*
|
||||
* [entityKind] is null for a sub-kind this client does not model (Pins, vsk 11;
|
||||
* Signals, vsk 12; anything newer): nothing here folds such an edition into state, but
|
||||
* it is kept — [vsk] carries its raw sub-kind — so the anti-rollback floor and a
|
||||
* Refounding's compaction carry its head forward verbatim instead of silently dropping
|
||||
* another client's state (CORD-04 §7, CORD-06 §3).
|
||||
*/
|
||||
class ControlEdition(
|
||||
val entityKind: ControlEntityKind,
|
||||
val entityKind: ControlEntityKind?,
|
||||
val entityId: ByteArray,
|
||||
val version: Long,
|
||||
val prevHash: ByteArray?,
|
||||
@@ -64,6 +75,8 @@ class ControlEdition(
|
||||
/** The rumor's event id — the deterministic tie-break key at equal version. */
|
||||
val rumorId: String,
|
||||
val createdAt: Long,
|
||||
/** The raw `vsk` sub-kind on the wire; [entityKind]'s wire value when it is modeled. */
|
||||
val vsk: String = entityKind?.wire ?: "",
|
||||
) {
|
||||
/** Domain-separated edition identity; the next edition's `ep` cites this. */
|
||||
val hash: ByteArray by lazy { EditionHash.hash(entityId, version, prevHash, content) }
|
||||
@@ -71,20 +84,59 @@ class ControlEdition(
|
||||
val entityIdHex: String get() = entityId.toHexKey()
|
||||
val hashHex: String get() = hash.toHexKey()
|
||||
|
||||
/** This edition carrying [citation] as its `vac`. The hash does not cover it, so the chain is unchanged. */
|
||||
fun withCitation(citation: AuthorityCitation?): ControlEdition = ControlEdition(entityKind, entityId, version, prevHash, citation, content, author, rumorId, createdAt, vsk)
|
||||
|
||||
companion object {
|
||||
/** The machinery tags an edition may carry at most once each (Armada `parseEdition`). */
|
||||
private val SINGLE_VALUED_TAGS = arrayOf(VskTag.TAG_NAME, EidTag.TAG_NAME, EvTag.TAG_NAME, EpTag.TAG_NAME, VacTag.TAG_NAME)
|
||||
|
||||
/**
|
||||
* Sub-kinds that are never Control Plane editions (CORD-02 Appendix B): 6 and 9 are
|
||||
* claimed by the addressable kind-33301 invite marker, 7 is retired (the v1 owner
|
||||
* attestation), and 10 is the dissolution tombstone, which lives at its own address and
|
||||
* must never be read off this plane (see `ConcordDissolution`).
|
||||
*/
|
||||
private val NOT_CONTROL_EDITIONS =
|
||||
setOf(ControlEntityKind.INVITE_LIVE.wire, "7", ControlEntityKind.INVITE_REVOKED.wire, ControlEntityKind.DISSOLVED.wire)
|
||||
|
||||
/**
|
||||
* Parses an opened Control Plane wrap into a [ControlEdition], or null when it is not
|
||||
* one. On top of [fromRumor] it enforces the plane's seal kind: a Control edition
|
||||
* **MUST** ride a plaintext kind-20014 seal (CORD-02 §5, Appendix B), since only a
|
||||
* plaintext seal survives a compaction re-wrap with its signature intact. An edition
|
||||
* under an encrypted 20013 seal is refused, as the reference client refuses it.
|
||||
*/
|
||||
fun fromOpened(opened: OpenedStreamEvent): ControlEdition? {
|
||||
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT) return null
|
||||
return fromRumor(opened.rumor, opened.author)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a decrypted, verified kind-3308 [rumor] (its [author] is the
|
||||
* rumor's pubkey) into a [ControlEdition], or returns null if it is not a
|
||||
* well-formed control edition (unknown/absent `vsk`, missing `eid`/`ev`,
|
||||
* malformed hex, …) so the caller drops it rather than folding garbage.
|
||||
* Reads the typed tags of [ControlEditionEvent].
|
||||
* well-formed control edition, so the caller drops it rather than folding
|
||||
* garbage: an absent or non-canonical `vsk`, a sub-kind that is not a Control
|
||||
* edition (6, 7, 9, 10), a missing or malformed `eid`/`ev`, a malformed `ep`/`vac`,
|
||||
* or any of those machinery tags appearing more than once (an ambiguous edition
|
||||
* two readers could parse differently). A canonical `vsk` this client does not
|
||||
* model parses with a null [ControlEdition.entityKind] (see the class doc).
|
||||
*
|
||||
* Prefer [fromOpened] for anything read off a plane: this does not see the seal.
|
||||
*/
|
||||
fun fromRumor(
|
||||
rumor: Event,
|
||||
author: String = rumor.pubKey,
|
||||
): ControlEdition? {
|
||||
if (rumor.kind != ControlEditionEvent.KIND) return null
|
||||
val entityKind = rumor.tags.vsk() ?: return null
|
||||
for (name in SINGLE_VALUED_TAGS) {
|
||||
if (rumor.tags.count { it.isNotEmpty() && it[0] == name } > 1) return null
|
||||
}
|
||||
|
||||
val vskWire = rumor.tags.firstOrNull { it.size >= 2 && it[0] == VskTag.TAG_NAME }?.get(1) ?: return null
|
||||
if (!CanonicalDecimal.isCanonical(vskWire) || vskWire in NOT_CONTROL_EDITIONS) return null
|
||||
val entityKind = ControlEntityKind.of(vskWire)
|
||||
|
||||
val entityId = rumor.tags.eid() ?: return null
|
||||
val version = rumor.tags.ev() ?: return null
|
||||
|
||||
@@ -107,6 +159,7 @@ class ControlEdition(
|
||||
author = author,
|
||||
rumorId = rumor.id,
|
||||
createdAt = rumor.createdAt,
|
||||
vsk = vskWire,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+108
-7
@@ -21,11 +21,20 @@
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.descriptors.elementNames
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* JSON facility for Concord Control Plane entity content. Unknown keys are
|
||||
@@ -47,6 +56,37 @@ object ConcordJson {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes [value] as the next edition's content **without losing what the previous edition
|
||||
* carried and we don't model** (CORD-02 §6: "an editor MUST round-trip fields it doesn't
|
||||
* understand"). Every key [serializer] declares is ours to set — including to absent, so a form
|
||||
* can clear an optional field — and every other key of [previousContent] (another client's
|
||||
* `custom`, a newer protocol field like `av_brokers`) rides through verbatim.
|
||||
*
|
||||
* [previousContent] is the entity's current authorized head, or null for a genesis edition. A
|
||||
* head that is not a JSON object contributes nothing.
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun <T> encodePreserving(
|
||||
serializer: KSerializer<T>,
|
||||
value: T,
|
||||
previousContent: String?,
|
||||
): String {
|
||||
val next = instance.encodeToJsonElement(serializer, value).jsonObject
|
||||
val previous =
|
||||
previousContent?.let {
|
||||
try {
|
||||
instance.parseToJsonElement(it) as? JsonObject
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
} ?: return instance.encodeToString(JsonObject.serializer(), next)
|
||||
val managed = serializer.descriptor.elementNames.toSet()
|
||||
val kept = previous.filterKeys { it !in managed }
|
||||
if (kept.isEmpty()) return instance.encodeToString(JsonObject.serializer(), next)
|
||||
return instance.encodeToString(JsonObject.serializer(), JsonObject(next + kept))
|
||||
}
|
||||
|
||||
/** Parses a Banlist edition's content (a bare JSON array of hex pubkeys). */
|
||||
fun decodeBanlist(content: String): List<String>? =
|
||||
try {
|
||||
@@ -71,11 +111,17 @@ data class RoleScope(
|
||||
|
||||
/**
|
||||
* A Role's content (CORD-04): a named bundle of permissions at a [position].
|
||||
* The role's id is the edition's entity id, not a content field. Lower [position]
|
||||
* ranks higher; no role may claim position 0 (reserved for the owner).
|
||||
* Lower [position] ranks higher; no role may claim position 0 (reserved for the owner).
|
||||
*
|
||||
* [roleId] is the role's own id, which the spec puts in the content (CORD-04 §2) and which
|
||||
* must equal the edition's `eid`. The reference client drops a role without it, so every
|
||||
* role we write carries it; roles minted before this client wrote it are still read (the
|
||||
* `eid` is then the id), but a role whose [roleId] names a *different* coordinate is refused
|
||||
* ([isWellFormedAt]).
|
||||
*/
|
||||
@Serializable
|
||||
data class RoleEntity(
|
||||
@SerialName("role_id") val roleId: String? = null,
|
||||
val name: String = "",
|
||||
val position: Long = 0,
|
||||
/** u64 permission bitfield as a decimal string. */
|
||||
@@ -86,6 +132,18 @@ data class RoleEntity(
|
||||
val deleted: Boolean = false,
|
||||
) {
|
||||
fun permissionBits(): ConcordPermissions = ConcordPermissions.fromWireOrNull(permissions) ?: ConcordPermissions.NONE
|
||||
|
||||
/**
|
||||
* Whether this content may stand as the role at coordinate [entityIdHex] (CORD-04 §2/§3):
|
||||
* its [roleId], when present, names that coordinate; its name fits the 64-byte cap; and a
|
||||
* live role claims a position below the owner's 0. Mirrors Armada's `roleFromJSON`, except
|
||||
* that a legacy role with no [roleId] is still accepted.
|
||||
*/
|
||||
fun isWellFormedAt(entityIdHex: String): Boolean {
|
||||
if (roleId != null && !roleId.equals(entityIdHex, ignoreCase = true)) return false
|
||||
if (!ConcordLimits.nameFits(name)) return false
|
||||
return deleted || position >= 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,16 +168,35 @@ data class GrantEntity(
|
||||
|
||||
/**
|
||||
* A Channel's content (CORD-03). The channel id is the edition entity id.
|
||||
* [private] selects derived-key visibility; [voice] flags an audio channel.
|
||||
* A [deleted] channel is terminal — its id is never reused.
|
||||
* [private] selects derived-key visibility. A [deleted] channel is terminal — its id is never
|
||||
* reused.
|
||||
*
|
||||
* There is no voice flag: every Channel is callable (CORD-07). A `voice` key an older client
|
||||
* wrote is not ours to interpret; it rides through edits untouched like any unknown field
|
||||
* ([ConcordJson.encodePreserving]), as does the optional `custom` object (CORD-02 §6).
|
||||
*/
|
||||
@Serializable
|
||||
data class ChannelEntity(
|
||||
val name: String = "",
|
||||
val private: Boolean = false,
|
||||
val voice: Boolean = false,
|
||||
val deleted: Boolean = false,
|
||||
)
|
||||
) {
|
||||
/** True when [name] is within the protocol's name rule ([isValidName]). */
|
||||
fun hasValidName(): Boolean = isValidName(name)
|
||||
|
||||
companion object {
|
||||
/** The protocol-wide name cap, in UTF-8 bytes (CORD-03 §2, CORD-04). */
|
||||
const val NAME_MAX_BYTES = 64
|
||||
|
||||
/**
|
||||
* A Channel name must be non-empty and at most [NAME_MAX_BYTES] UTF-8 bytes. Enforced when
|
||||
* building an edition and again when folding one: an edition naming an empty or over-cap
|
||||
* Channel is unauthorized, and the fold falls back to the previous candidate (the reference
|
||||
* client's channel gate).
|
||||
*/
|
||||
fun isValidName(name: String): Boolean = name.isNotEmpty() && name.encodeToByteArray().size <= NAME_MAX_BYTES
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A community's Metadata content (CORD-02): display [name], optional [description], the community's
|
||||
@@ -137,4 +214,28 @@ data class MetadataEntity(
|
||||
val banner: ImagePointer? = null,
|
||||
val description: String? = null,
|
||||
val relays: List<String> = emptyList(),
|
||||
)
|
||||
/**
|
||||
* The disappearing-messages timer (CORD-08 §1) exactly as the edition carried it. Kept raw so
|
||||
* a malformed value is carried through an edit untouched; read it through [messageExpirationSecs].
|
||||
*/
|
||||
@SerialName("message_expiration") val messageExpiration: JsonElement? = null,
|
||||
) {
|
||||
/**
|
||||
* The disappearing-messages timer in whole seconds, or null when it is off (CORD-08 §1).
|
||||
* Absent, `0`, negative or malformed (a string, an object, a non-finite number) all read as
|
||||
* off — a reader MUST NOT guess a default from garbage. A fractional value floors, as the
|
||||
* reference client does.
|
||||
*/
|
||||
fun messageExpirationSecs(): Long? {
|
||||
val primitive = messageExpiration as? JsonPrimitive ?: return null
|
||||
if (primitive.isString) return null
|
||||
val value = primitive.doubleOrNull ?: return null
|
||||
if (!value.isFinite()) return null
|
||||
val secs = floor(value)
|
||||
if (secs < 1 || secs > Long.MAX_VALUE.toDouble()) return null
|
||||
return secs.toLong()
|
||||
}
|
||||
|
||||
/** This metadata with the timer set to [secs], or turned off when [secs] is null or below 1. */
|
||||
fun withMessageExpiration(secs: Long?): MetadataEntity = copy(messageExpiration = secs?.takeIf { it >= 1 }?.let { JsonPrimitive(it) })
|
||||
}
|
||||
|
||||
+3
@@ -56,6 +56,9 @@ enum class ControlEntityKind(
|
||||
|
||||
/** The dissolution tombstone (terminal). */
|
||||
DISSOLVED("10"),
|
||||
|
||||
/** A Channel's Pin List (CORD-04 §7), gated by PIN_MESSAGES. */
|
||||
PIN_LIST("11"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
+128
-52
@@ -60,6 +60,12 @@ fun ControlEdition.asFloor(): EntityFloor = EntityFloor(version, hashHex, this)
|
||||
*/
|
||||
typealias GapReporter = (entityIdHex: String, floorVersion: Long, offeredVersion: Long) -> Unit
|
||||
|
||||
/**
|
||||
* An author's standing for the equal-version tie-break ([EditionFold.pickHead]): lower is
|
||||
* higher authority — the owner lowest, then Role position, a roleless author last.
|
||||
*/
|
||||
typealias AuthorRank = (author: String) -> Long
|
||||
|
||||
/**
|
||||
* Folds Control Plane editions into the current head of each entity (CORD-04
|
||||
* §Edition Hashing & Chain Integrity).
|
||||
@@ -88,12 +94,14 @@ typealias GapReporter = (entityIdHex: String, floorVersion: Long, offeredVersion
|
||||
* - **Intact chain / no downgrades** — the head advances to `version + 1` only
|
||||
* when that edition's `ep` cites the current head's [ControlEdition.hash].
|
||||
* Lower or non-chaining versions are ignored.
|
||||
* - **Deterministic convergence** — at equal version, ties break on the lower
|
||||
* rumor id, so every honest client folds to the same head.
|
||||
* - **Deterministic convergence** — at equal version the spec's tie-break is
|
||||
* "authority first, then the lower rumor id, never the author-settable
|
||||
* timestamp" (CORD-04 §1). The bare structural fold only knows the rumor id;
|
||||
* [foldEntityGated] / [foldGated] with a rank apply authority first both in the
|
||||
* walk (which sibling anchors and extends the chain) and in the head pick
|
||||
* ([pickHead]), so every honest client lands on the same head.
|
||||
*
|
||||
* Authority-weighted tie-break ("authority first, then the lower rumor id") and
|
||||
* the owner-rooted `vac` verification are applied by the resolver layer on top of
|
||||
* this structural fold; this class is purely the chain walk.
|
||||
* The owner-rooted `vac` verification rides the callers' gates.
|
||||
*/
|
||||
object EditionFold {
|
||||
private const val TAG = "ConcordEditionFold"
|
||||
@@ -148,10 +156,17 @@ object EditionFold {
|
||||
private fun bootstrapHead(
|
||||
editions: List<ControlEdition>,
|
||||
floorVersion: Long,
|
||||
tie: Comparator<ControlEdition>,
|
||||
): ControlEdition? =
|
||||
editions
|
||||
.filter { it.version >= floorVersion && it.version - floorVersion <= MAX_COMPACTION_VERSION_JUMP }
|
||||
.minWithOrNull(compareByDescending<ControlEdition> { it.version }.thenBy { it.rumorId })
|
||||
.minWithOrNull(compareByDescending<ControlEdition> { it.version }.then(tie))
|
||||
|
||||
/**
|
||||
* The structural tie-break among equal-version siblings when nothing better is known: the
|
||||
* lower rumor id. [foldEntityGated] replaces it with authority first (CORD-04 §1).
|
||||
*/
|
||||
val BY_RUMOR_ID: Comparator<ControlEdition> = compareBy { it.rumorId }
|
||||
|
||||
/**
|
||||
* How far above the floor the compaction arm will follow an edition in one step.
|
||||
@@ -183,30 +198,46 @@ object EditionFold {
|
||||
private fun chainHead(
|
||||
editions: List<ControlEdition>,
|
||||
floor: EntityFloor,
|
||||
tie: Comparator<ControlEdition>,
|
||||
): 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
|
||||
}
|
||||
var head = floorAnchor(byVersion[lowest] ?: return null, lowest, floor, tie) ?: return null
|
||||
while (true) head = nextLink(byVersion, head, tie) ?: break
|
||||
return head
|
||||
}
|
||||
|
||||
/**
|
||||
* The edition at the lowest offered version [lowest] (at or above the floor) that connects
|
||||
* to [floor], or null. Only two shapes connect: the floor edition itself (same version AND
|
||||
* hash — a same-version sibling is a fork, not our chain, but its presence does not hide
|
||||
* the edition we hold), or the floor's immediate successor citing the floor's hash, the
|
||||
* [tie] winner among several.
|
||||
*/
|
||||
private fun floorAnchor(
|
||||
siblings: List<ControlEdition>,
|
||||
lowest: Long,
|
||||
floor: EntityFloor,
|
||||
tie: Comparator<ControlEdition>,
|
||||
): ControlEdition? =
|
||||
when (lowest) {
|
||||
floor.version -> siblings.firstOrNull { it.hashHex == floor.hashHex }
|
||||
floor.version + 1 -> siblings.filter { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex }.minWithOrNull(tie)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** The [tie] winner among the `version + 1` editions citing [head], or null when the chain ends there. */
|
||||
private fun nextLink(
|
||||
byVersion: Map<Long, List<ControlEdition>>,
|
||||
head: ControlEdition,
|
||||
tie: Comparator<ControlEdition>,
|
||||
): ControlEdition? =
|
||||
byVersion[head.version + 1]
|
||||
?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex }
|
||||
?.minWithOrNull(tie)
|
||||
|
||||
/**
|
||||
* Groups mixed [editions] by entity id and folds each to its head, honoring the
|
||||
* per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]).
|
||||
@@ -225,7 +256,7 @@ object EditionFold {
|
||||
val byEntity = editions.groupBy { it.entityIdHex }
|
||||
val out = HashMap<String, ControlEdition>(byEntity.size)
|
||||
for ((entity, list) in byEntity) {
|
||||
foldEntity(list, floors[entity], snapshot, onGap)?.let { out[entity] = it }
|
||||
foldEntity(list, floors[entity], snapshot, onGap = onGap)?.let { out[entity] = it }
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -246,11 +277,18 @@ object EditionFold {
|
||||
* the version-anchored compaction arm instead of the chain walk — see the arm
|
||||
* itself for why. Null (the default) keeps the pure chain walk, which is right for
|
||||
* a single-epoch fold and for every caller that has no epoch to speak of.
|
||||
*
|
||||
* [tie] picks among equal-version siblings wherever the walk has a choice (the genesis
|
||||
* anchor, each next link, the compaction head): the lower rumor id by default, authority
|
||||
* first when [foldEntityGated] supplies one. Without authority in the walk, a lower-ranked
|
||||
* member's fork with a grindable low rumor id would anchor the chain, and an honest
|
||||
* successor chained onto the owner's sibling would be unreachable.
|
||||
*/
|
||||
fun foldEntity(
|
||||
editions: List<ControlEdition>,
|
||||
floor: EntityFloor? = null,
|
||||
snapshot: Set<String>? = null,
|
||||
tie: Comparator<ControlEdition> = BY_RUMOR_ID,
|
||||
onGap: GapReporter = LOG_GAP,
|
||||
): ControlEdition? {
|
||||
if (editions.isEmpty()) return floor?.known
|
||||
@@ -269,8 +307,8 @@ object EditionFold {
|
||||
// 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)
|
||||
chainHead(editions, floor, tie)?.let { return it }
|
||||
return bootstrapHead(editions, floor.version, tie)
|
||||
?: run {
|
||||
// Nothing admissible at or above the floor was served: the head we already
|
||||
// accepted vanished from the offered set — withheld, so fail closed.
|
||||
@@ -279,8 +317,7 @@ object EditionFold {
|
||||
}
|
||||
}
|
||||
|
||||
// Index editions by version, keeping the tie-break winner where several
|
||||
// share a version (lower rumor id wins).
|
||||
// Index editions by version; where several share one, [tie] picks among them.
|
||||
val byVersion = HashMap<Long, MutableList<ControlEdition>>()
|
||||
for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e)
|
||||
|
||||
@@ -295,15 +332,7 @@ object EditionFold {
|
||||
// refuse; walking up from the anchor also makes a head below the floor version
|
||||
// structurally impossible.
|
||||
val lowest = byVersion.keys.filter { it >= floor.version }.minOrNull()
|
||||
val winner = lowest?.let { v -> byVersion[v]?.minByOrNull { it.rumorId } }
|
||||
val anchor =
|
||||
when {
|
||||
winner == null -> null
|
||||
lowest == floor.version -> winner.takeIf { it.hashHex == floor.hashHex }
|
||||
lowest == floor.version + 1 ->
|
||||
winner.takeIf { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex }
|
||||
else -> null
|
||||
}
|
||||
val anchor = lowest?.let { v -> floorAnchor(byVersion[v] ?: emptyList(), v, floor, tie) }
|
||||
anchor
|
||||
?: run {
|
||||
onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version })
|
||||
@@ -315,22 +344,16 @@ object EditionFold {
|
||||
// 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.
|
||||
val byVersionThenTie = compareBy<ControlEdition> { it.version }.then(tie)
|
||||
editions
|
||||
.filter { it.prevHash == null }
|
||||
.minWithOrNull(compareBy({ it.version }, { it.rumorId }))
|
||||
?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId }))
|
||||
.minWithOrNull(byVersionThenTie)
|
||||
?: editions.minWithOrNull(byVersionThenTie)
|
||||
?: return null
|
||||
}
|
||||
|
||||
// Walk the chain upward while the next version chains from the current head.
|
||||
while (true) {
|
||||
val next =
|
||||
byVersion[head.version + 1]
|
||||
?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex }
|
||||
?.minByOrNull { it.rumorId }
|
||||
?: break
|
||||
head = next
|
||||
}
|
||||
while (true) head = nextLink(byVersion, head, tie) ?: break
|
||||
return head
|
||||
}
|
||||
|
||||
@@ -370,13 +393,14 @@ object EditionFold {
|
||||
floor: EntityFloor? = null,
|
||||
snapshot: Set<String>? = null,
|
||||
onGap: GapReporter = LOG_GAP,
|
||||
tie: Comparator<ControlEdition> = BY_RUMOR_ID,
|
||||
): List<ControlEdition> {
|
||||
// Ask the fold whether it gapped rather than re-deriving the condition here: with the
|
||||
// compaction arm and the successor anchor there are three ways to connect, and a second
|
||||
// copy of that test is a bug waiting to drift out of sync with the first.
|
||||
var gapped = false
|
||||
val head =
|
||||
foldEntity(editions, floor, snapshot) { e, f, o ->
|
||||
foldEntity(editions, floor, snapshot, tie) { e, f, o ->
|
||||
gapped = true
|
||||
onGap(e, f, o)
|
||||
} ?: return emptyList()
|
||||
@@ -389,7 +413,7 @@ object EditionFold {
|
||||
out.add(head)
|
||||
editions
|
||||
.filterTo(ArrayList()) { it.rumorId != head.rumorId && (floor == null || it.version >= floor.version) }
|
||||
.sortedWith(compareByDescending<ControlEdition> { it.version }.thenBy { it.rumorId })
|
||||
.sortedWith(compareByDescending<ControlEdition> { it.version }.then(tie))
|
||||
.let(out::addAll)
|
||||
return out
|
||||
}
|
||||
@@ -397,31 +421,83 @@ object EditionFold {
|
||||
/**
|
||||
* The head of one entity: the highest-priority [candidates] entry that passes
|
||||
* [gate], or null when none does. See [candidates] for why the gate is applied
|
||||
* *after* the chain walk rather than before it.
|
||||
* *after* the chain walk rather than before it, and [pickHead] for how [rank]
|
||||
* settles an equal-version tie.
|
||||
*/
|
||||
fun foldEntityGated(
|
||||
editions: List<ControlEdition>,
|
||||
floor: EntityFloor? = null,
|
||||
snapshot: Set<String>? = null,
|
||||
onGap: GapReporter = LOG_GAP,
|
||||
rank: AuthorRank? = null,
|
||||
gate: (ControlEdition) -> Boolean,
|
||||
): ControlEdition? = candidates(editions, floor, snapshot, onGap).firstOrNull(gate)
|
||||
): ControlEdition? {
|
||||
if (rank == null) return pickHead(candidates(editions, floor, snapshot, onGap), null, gate)
|
||||
// Authority first everywhere the walk has a choice, judged only over editions the gate
|
||||
// admits (a rejected sibling ranks last, so it can never anchor the chain on authority it
|
||||
// does not hold), then the lower rumor id. Memoized: a gate decodes the content.
|
||||
val score = HashMap<String, Long>()
|
||||
val tie =
|
||||
compareBy<ControlEdition> { e -> score.getOrPut(e.rumorId) { if (gate(e)) rank(e.author) else Long.MAX_VALUE } }
|
||||
.then(BY_RUMOR_ID)
|
||||
return pickHead(candidates(editions, floor, snapshot, onGap, tie), rank, gate)
|
||||
}
|
||||
|
||||
/**
|
||||
* The first of the ordered [candidates] passing [gate], with an equal-version tie broken
|
||||
* **authority first** (CORD-04 §1: "authority first, then the lower rumor id, never the
|
||||
* author-settable timestamp"): among the gate-passing candidates at the winner's version,
|
||||
* the one whose author [rank]s highest (lowest number) takes it, and only a rank tie falls
|
||||
* back to the candidate order — the chain-verified head, then the lower rumor id.
|
||||
*
|
||||
* A rumor id is author-grindable, so without this a lower-ranked bit holder could mint
|
||||
* editions until one sorted below the owner's at the same version and win the entity.
|
||||
* This is Armada's `pickHead` (control.ts), which both clients must agree on. With no
|
||||
* [rank] the first passing candidate wins, the old rumor-id-only rule.
|
||||
*/
|
||||
fun pickHead(
|
||||
candidates: List<ControlEdition>,
|
||||
rank: AuthorRank?,
|
||||
gate: (ControlEdition) -> Boolean,
|
||||
): ControlEdition? {
|
||||
var head: ControlEdition? = null
|
||||
var headRank = 0L
|
||||
for (c in candidates) {
|
||||
if (!gate(c)) continue
|
||||
if (head == null) {
|
||||
if (rank == null) return c
|
||||
head = c
|
||||
headRank = rank(c.author)
|
||||
continue
|
||||
}
|
||||
// Candidates are version-descending after the head: nothing at a lower version can win.
|
||||
if (c.version != head.version) break
|
||||
val r = rank!!(c.author)
|
||||
if (r < headRank) {
|
||||
head = c
|
||||
headRank = r
|
||||
}
|
||||
}
|
||||
return head
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups mixed [editions] by entity id and folds each to the highest-priority
|
||||
* head passing [gate] — the gated counterpart of [fold]. See [candidates].
|
||||
* head passing [gate] — the gated counterpart of [fold]. See [candidates] and
|
||||
* [pickHead].
|
||||
*/
|
||||
fun foldGated(
|
||||
editions: Collection<ControlEdition>,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
snapshot: Set<String>? = null,
|
||||
onGap: GapReporter = LOG_GAP,
|
||||
rank: AuthorRank? = null,
|
||||
gate: (ControlEdition) -> Boolean,
|
||||
): Map<String, ControlEdition> {
|
||||
val byEntity = editions.groupBy { it.entityIdHex }
|
||||
val out = HashMap<String, ControlEdition>(byEntity.size)
|
||||
for ((entity, list) in byEntity) {
|
||||
foldEntityGated(list, floors[entity], snapshot, onGap, gate)?.let { out[entity] = it }
|
||||
foldEntityGated(list, floors[entity], snapshot, onGap, rank, gate)?.let { out[entity] = it }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.control.tags
|
||||
|
||||
/**
|
||||
* The strict tag-number form CORD-01 §5 fixes for the Control Plane's machinery tags
|
||||
* (`vsk`, `ev`, a `vac` version): decimal with no sign and no leading zeros — `0`, `4`,
|
||||
* `12`, never `04`, `+4`, `0x4` or `1e2`.
|
||||
*
|
||||
* `String.toLongOrNull()` accepts `+4` and `04`, so two clients reading the same edition
|
||||
* could disagree about which version it is. The reference client (Armada `isTagDecimal`)
|
||||
* refuses anything else, and so do we.
|
||||
*/
|
||||
object CanonicalDecimal {
|
||||
fun isCanonical(s: String): Boolean {
|
||||
if (s.isEmpty()) return false
|
||||
if (s.length > 1 && s[0] == '0') return false
|
||||
for (c in s) if (c !in '0'..'9') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** [s] as a non-negative Long when it is canonical and fits, else null. */
|
||||
fun parse(s: String): Long? = if (isCanonical(s)) s.toLongOrNull() else null
|
||||
}
|
||||
+2
-1
@@ -33,7 +33,8 @@ class EvTag {
|
||||
fun parse(tag: Array<String>): Long? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return tag[1].toLongOrNull()?.takeIf { it >= 0 }
|
||||
// Canonical decimal only: "04" or "+4" is not a version (CORD-01 §5).
|
||||
return CanonicalDecimal.parse(tag[1])
|
||||
}
|
||||
|
||||
fun assemble(version: Long) = arrayOf(TAG_NAME, version.toString())
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ class VacTag {
|
||||
ensure(tag.has(3)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
val grantId = tag[1].hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null
|
||||
val grantVersion = tag[2].toLongOrNull() ?: return null
|
||||
val grantVersion = CanonicalDecimal.parse(tag[2]) ?: return null
|
||||
val grantHash = tag[3].hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null
|
||||
return AuthorityCitation(grantId, grantVersion, grantHash)
|
||||
}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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.pins
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
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.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
|
||||
/**
|
||||
* Folds the Control Plane's Pin Lists (CORD-04 §7): one vsk-11 entity per Channel, at
|
||||
* `pins_locator(community_id, channel_id)`, gated like any edition by `PIN_MESSAGES` (the owner
|
||||
* always passes). An edition at any other coordinate — a list minted for another Community or a
|
||||
* Channel that isn't folded here — binds to nothing and is ignored.
|
||||
*/
|
||||
object ConcordPinLists {
|
||||
/** The Pin List coordinate (hex) of [channelIdHex] in [communityIdHex]. */
|
||||
fun coordinate(
|
||||
communityIdHex: HexKey,
|
||||
channelIdHex: HexKey,
|
||||
): HexKey = ConcordKeyDerivation.pinsCoordinate(communityIdHex.hexToByteArray(), channelIdHex.hexToByteArray()).toHexKey()
|
||||
|
||||
/** Per channel id, the authorized head edition of its Pin List. Channels with no list are absent. */
|
||||
fun heads(
|
||||
editions: Collection<ControlEdition>,
|
||||
authority: AuthorityResolver,
|
||||
communityIdHex: HexKey,
|
||||
channelIds: Collection<HexKey>,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
): Map<HexKey, ControlEdition> {
|
||||
if (channelIds.isEmpty()) return emptyMap()
|
||||
val channelByCoordinate = channelIds.associateBy { coordinate(communityIdHex, it) }
|
||||
val lists = editions.filter { it.entityKind == ControlEntityKind.PIN_LIST && it.entityIdHex in channelByCoordinate }
|
||||
if (lists.isEmpty()) return emptyMap()
|
||||
return EditionFold
|
||||
// Same gate every other entity folds under: well-formed, owner or a PIN_MESSAGES holder, vac satisfied.
|
||||
.foldGated(lists, floors, rank = authority::tieBreakRank) { authority.admits(it, ConcordPermissions.PIN_MESSAGES) }
|
||||
.mapNotNull { (coordinate, head) -> channelByCoordinate[coordinate]?.let { it to head } }
|
||||
.toMap()
|
||||
}
|
||||
}
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* 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.pins
|
||||
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
/**
|
||||
* Concord Pins (CORD-04 §7): a pin does not quote a message, it **proves** one.
|
||||
*
|
||||
* One Pin List per Channel lives on the Control Plane (vsk 11, coordinate
|
||||
* `pins_locator(community_id, channel_id)`), replaced entire per edit. Each entry carries the
|
||||
* message's original kind-20013 seal, verbatim, plus the 76-byte NIP-44 key disclosure for that one
|
||||
* message ([PinKeyDisclosure]) — so any reader of the Control Plane, with no Chat-plane history and
|
||||
* no old keys, can verify author, words, Channel and time. Compaction (CORD-06) re-wraps the list
|
||||
* across rotations, which is how a pin reaches members who joined long after the message.
|
||||
*
|
||||
* An entry's wire shape is `{ "seal": {…}, "keys": "<152 hex>", "wrap"?: "<hex id>", "edit"?:
|
||||
* {"seal", "keys"} }`. Entries are kept as the raw JSON they arrived as: the seal's fields must be
|
||||
* carried exactly (its signature covers them), and fields we don't model must survive a republish.
|
||||
*/
|
||||
object ConcordPins {
|
||||
/** At most this many entries — judged by whoever can open the form. */
|
||||
const val MAX_ENTRIES = 25
|
||||
|
||||
/** At most this many bytes of edition `content`, judged on the carried bytes, both forms. */
|
||||
const val MAX_CONTENT_BYTES = 32_768
|
||||
|
||||
const val KIND_MESSAGE = 9
|
||||
const val KIND_COMMENT = 1111
|
||||
const val KIND_EDIT = 3302
|
||||
|
||||
private val json = Json { prettyPrint = false }
|
||||
private val HEX64 = Regex("^[0-9a-f]{64}$")
|
||||
private val DECIMAL = Regex("^(0|[1-9][0-9]*)$")
|
||||
|
||||
/** A pin entry that passed every §7 check — safe to render. */
|
||||
class VerifiedPin(
|
||||
/** Recomputed from the decrypted bytes; never an embedded id. The entry's identity. */
|
||||
val rumorId: HexKey,
|
||||
/** The seal's signer, equal to the rumor's author. */
|
||||
val author: HexKey,
|
||||
val kind: Int,
|
||||
/** The newest proven words: the attached Edit's when one verified, else the original's. */
|
||||
val content: String,
|
||||
val tags: Array<Array<String>>,
|
||||
/** The message's own `epoch` tag, for jump-to-context. */
|
||||
val epoch: String?,
|
||||
/** Ordering basis: `created_at * 1000 + ms`. */
|
||||
val orderMs: Long,
|
||||
val createdAt: Long,
|
||||
/** The unverifiable locator hint the entry carried, if any. */
|
||||
val wrapHint: HexKey?,
|
||||
/** True when a proven Edit supplied [content]. */
|
||||
val edited: Boolean,
|
||||
/** The proven Edit's rumor id, when one verified. */
|
||||
val editRumorId: HexKey?,
|
||||
/** The wire entry, verbatim, for republishing. */
|
||||
val entry: JsonObject,
|
||||
)
|
||||
|
||||
/** How a Pin List's content read (§7 Limits). */
|
||||
class PinListRead(
|
||||
val entries: List<JsonObject>,
|
||||
/**
|
||||
* True when the list is the sealed form under an epoch key this reader doesn't hold. Such a
|
||||
* list is *unavailable*, not empty — a writer MUST NOT build an edition from it.
|
||||
*/
|
||||
val sealedUnavailable: Boolean,
|
||||
/** True when the content broke a cap or the format, so every reader treats it as empty. */
|
||||
val violating: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = PinListRead(emptyList(), sealedUnavailable = false, violating = false)
|
||||
val VIOLATING = PinListRead(emptyList(), sealedUnavailable = false, violating = true)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- reading ---------------------------------------------------------------------------
|
||||
|
||||
private fun parse(s: String): JsonElement? =
|
||||
try {
|
||||
json.parseToJsonElement(s)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a Pin List edition's [content]. A cap-violating or malformed edition reads as an empty
|
||||
* list (it still folds and chains — refusing it would fork the version chain between
|
||||
* implementations). The sealed form opens with [unsealKey], the Channel's conversation key at
|
||||
* the named epoch, or reads as [PinListRead.sealedUnavailable] when this reader lacks it.
|
||||
*/
|
||||
fun read(
|
||||
content: String,
|
||||
unsealKey: (epoch: Long) -> ByteArray?,
|
||||
): PinListRead {
|
||||
if (content.encodeToByteArray().size > MAX_CONTENT_BYTES) return PinListRead.VIOLATING
|
||||
val root = parse(content) as? JsonObject ?: return PinListRead.VIOLATING
|
||||
val entries = root["entries"]
|
||||
if (entries != null) return entriesOf(entries)
|
||||
|
||||
val epoch = (root["epoch"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
val sealed = (root["sealed"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
if (epoch == null || sealed == null || !DECIMAL.matches(epoch)) return PinListRead.VIOLATING
|
||||
val epochValue = epoch.toLongOrNull() ?: return PinListRead.VIOLATING
|
||||
val key = unsealKey(epochValue) ?: return PinListRead(emptyList(), sealedUnavailable = true, violating = false)
|
||||
val inner =
|
||||
try {
|
||||
parse(Nip44.v2.decrypt(sealed, key)) as? JsonObject
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return PinListRead.VIOLATING
|
||||
return entriesOf(inner["entries"] ?: return PinListRead.VIOLATING)
|
||||
}
|
||||
|
||||
private fun entriesOf(element: JsonElement): PinListRead {
|
||||
val array = element as? JsonArray ?: return PinListRead.VIOLATING
|
||||
if (array.size > MAX_ENTRIES) return PinListRead.VIOLATING
|
||||
// A non-object entry is just an invalid entry, dropped alone by the verifier.
|
||||
return PinListRead(array.mapNotNull { it as? JsonObject }, sealedUnavailable = false, violating = false)
|
||||
}
|
||||
|
||||
// ---- verification ----------------------------------------------------------------------
|
||||
|
||||
private class OpenedRumor(
|
||||
val pubKey: HexKey,
|
||||
val kind: Int,
|
||||
val createdAt: Long,
|
||||
val tags: Array<Array<String>>,
|
||||
val content: String,
|
||||
) {
|
||||
val id: HexKey by lazy { EventHasher.hashId(pubKey, createdAt, kind, tags, content) }
|
||||
|
||||
fun tag(name: String): String? = tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1)
|
||||
|
||||
fun orderMs(): Long {
|
||||
val raw = tag("ms")
|
||||
val ms = raw?.takeIf { DECIMAL.matches(it) }?.toLongOrNull()?.takeIf { it <= 999 } ?: 0L
|
||||
return createdAt * 1000 + ms
|
||||
}
|
||||
}
|
||||
|
||||
/** The seal object as an [Event], only if it is a correctly signed kind-20013 seal. */
|
||||
private fun sealOf(element: JsonElement?): Event? {
|
||||
val obj = element as? JsonObject ?: return null
|
||||
val seal =
|
||||
try {
|
||||
Event.fromJson(json.encodeToString(JsonObject.serializer(), obj))
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
if (seal.kind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null
|
||||
val valid =
|
||||
try {
|
||||
seal.verify()
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
return seal.takeIf { valid }
|
||||
}
|
||||
|
||||
/** Steps 2–4 up to the rumor: MAC, decrypt, unpad, parse, author equality. */
|
||||
private fun openRumor(
|
||||
seal: Event,
|
||||
keysHex: String?,
|
||||
): OpenedRumor? {
|
||||
val keys = PinKeyDisclosure.decode(keysHex ?: return null) ?: return null
|
||||
val plaintext = PinKeyDisclosure.decryptWith(seal.content, keys) ?: return null
|
||||
val obj = parse(plaintext) as? JsonObject ?: return null
|
||||
val pubKey = (obj["pubkey"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
|
||||
val kind = (obj["kind"] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull ?: return null
|
||||
val createdAt = (obj["created_at"] as? JsonPrimitive)?.takeIf { !it.isString }?.longOrNull ?: return null
|
||||
val content = (obj["content"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
|
||||
val tags =
|
||||
(obj["tags"] as? JsonArray)?.map { tag ->
|
||||
(tag as? JsonArray)?.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content ?: return null }?.toTypedArray() ?: return null
|
||||
} ?: return null
|
||||
// NIP-59's impersonation check: renderers display rumor fields.
|
||||
if (pubKey != seal.pubKey) return null
|
||||
return OpenedRumor(pubKey, kind, createdAt, tags.toTypedArray(), content)
|
||||
}
|
||||
|
||||
/**
|
||||
* The five §7 verification steps, holding nothing but [entry] and its list's [channelIdHex]:
|
||||
* a signed kind-20013 seal; the MAC under the disclosed HMAC key; decrypt, unpad and parse; the
|
||||
* rumor's author equals the seal's, its kind is 9 or 1111, and it carries
|
||||
* `["channel", channelIdHex]`; and its id recomputed from the bytes. Null on any failure — the
|
||||
* entry is then dropped alone. A failing `edit` bundle costs only the revision, never the pin.
|
||||
*/
|
||||
fun verify(
|
||||
entry: JsonObject,
|
||||
channelIdHex: HexKey,
|
||||
): VerifiedPin? {
|
||||
if (!HEX64.matches(channelIdHex)) return null
|
||||
val seal = sealOf(entry["seal"]) ?: return null
|
||||
val rumor = openRumor(seal, (entry["keys"] as? JsonPrimitive)?.takeIf { it.isString }?.content) ?: return null
|
||||
if (rumor.kind != KIND_MESSAGE && rumor.kind != KIND_COMMENT) return null
|
||||
// The binding stops a private Channel's keyholder from pinning its messages into a public list.
|
||||
if (rumor.tag("channel") != channelIdHex) return null
|
||||
val rumorId = rumor.id
|
||||
|
||||
val edit = (entry["edit"] as? JsonObject)?.let { verifyEdit(it, seal.pubKey, rumorId, channelIdHex) }
|
||||
return VerifiedPin(
|
||||
rumorId = rumorId,
|
||||
author = seal.pubKey,
|
||||
kind = rumor.kind,
|
||||
content = edit?.content ?: rumor.content,
|
||||
tags = rumor.tags,
|
||||
epoch = rumor.tag("epoch"),
|
||||
orderMs = rumor.orderMs(),
|
||||
createdAt = rumor.createdAt,
|
||||
wrapHint = (entry["wrap"] as? JsonPrimitive)?.contentOrNull?.takeIf { HEX64.matches(it) },
|
||||
edited = edit != null,
|
||||
editRumorId = edit?.id,
|
||||
entry = entry,
|
||||
)
|
||||
}
|
||||
|
||||
/** An Edit bundle: the same steps with kind 3302, plus the same author and an `e` naming the original. */
|
||||
private fun verifyEdit(
|
||||
bundle: JsonObject,
|
||||
originalAuthor: HexKey,
|
||||
originalRumorId: HexKey,
|
||||
channelIdHex: HexKey,
|
||||
): OpenedRumor? {
|
||||
val seal = sealOf(bundle["seal"]) ?: return null
|
||||
if (seal.pubKey != originalAuthor) return null
|
||||
val rumor = openRumor(seal, (bundle["keys"] as? JsonPrimitive)?.takeIf { it.isString }?.content) ?: return null
|
||||
if (rumor.kind != KIND_EDIT) return null
|
||||
if (rumor.tag("channel") != channelIdHex) return null
|
||||
if (rumor.tag("e") != originalRumorId) return null
|
||||
return rumor
|
||||
}
|
||||
|
||||
// ---- writing ---------------------------------------------------------------------------
|
||||
|
||||
private fun sealJson(seal: Event): JsonObject = parse(seal.toJson()) as JsonObject
|
||||
|
||||
/**
|
||||
* Builds the entry for a message this client opened: its verbatim seal, the disclosure derived
|
||||
* with the Channel's [conversationKey] at the message's epoch, and the [wrapId] hint. Null when
|
||||
* the result would not verify (an unencrypted seal, a wrong epoch key — the MAC catches it).
|
||||
*/
|
||||
fun buildEntry(
|
||||
opened: OpenedStreamEvent,
|
||||
conversationKey: ByteArray,
|
||||
channelIdHex: HexKey,
|
||||
wrapId: HexKey?,
|
||||
): JsonObject? {
|
||||
val seal = opened.seal
|
||||
if (seal.kind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null
|
||||
val keys = PinKeyDisclosure.discloseFor(seal.content, conversationKey) ?: return null
|
||||
val fields = LinkedHashMap<String, JsonElement>()
|
||||
fields["seal"] = sealJson(seal)
|
||||
fields["keys"] = JsonPrimitive(PinKeyDisclosure.encode(keys))
|
||||
if (wrapId != null) fields["wrap"] = JsonPrimitive(wrapId)
|
||||
val entry = JsonObject(fields)
|
||||
return entry.takeIf { verify(it, channelIdHex) != null }
|
||||
}
|
||||
|
||||
/**
|
||||
* [entry] with the proof of [edit] attached (§7 Edits), replacing any earlier one — an entry
|
||||
* carries at most the newest provable Edit. Returns [entry] unchanged when the bundle would not
|
||||
* verify, so a refresh never downgrades it.
|
||||
*/
|
||||
fun withEdit(
|
||||
entry: JsonObject,
|
||||
edit: OpenedStreamEvent,
|
||||
conversationKey: ByteArray,
|
||||
channelIdHex: HexKey,
|
||||
): JsonObject {
|
||||
val seal = edit.seal
|
||||
if (seal.kind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return entry
|
||||
val keys = PinKeyDisclosure.discloseFor(seal.content, conversationKey) ?: return entry
|
||||
val bundle = JsonObject(mapOf("seal" to sealJson(seal), "keys" to JsonPrimitive(PinKeyDisclosure.encode(keys))))
|
||||
val candidate = JsonObject(entry + ("edit" to bundle))
|
||||
return if (verify(candidate, channelIdHex)?.edited == true) candidate else entry
|
||||
}
|
||||
|
||||
private fun listJson(entries: List<JsonObject>) = json.encodeToString(JsonObject.serializer(), JsonObject(mapOf("entries" to JsonArray(entries))))
|
||||
|
||||
/** Thrown instead of publishing an edition every reader would read as empty. */
|
||||
class PinListTooLargeException(
|
||||
message: String,
|
||||
) : IllegalArgumentException(message)
|
||||
|
||||
private fun checkCaps(
|
||||
count: Int,
|
||||
content: String,
|
||||
): String {
|
||||
if (count > MAX_ENTRIES) throw PinListTooLargeException("pin list exceeds $MAX_ENTRIES entries")
|
||||
val bytes = content.encodeToByteArray().size
|
||||
if (bytes > MAX_CONTENT_BYTES) throw PinListTooLargeException("pin list content is $bytes bytes (cap $MAX_CONTENT_BYTES)")
|
||||
return content
|
||||
}
|
||||
|
||||
/** A public Channel's list: plaintext, since the Control Plane's wrap is the gate. */
|
||||
fun serializePublic(entries: List<JsonObject>): String = checkCaps(entries.size, listJson(entries))
|
||||
|
||||
/** A private Channel's list, sealed under its [conversationKey] at [epoch]; caps judged on the final bytes. */
|
||||
fun serializeSealed(
|
||||
entries: List<JsonObject>,
|
||||
conversationKey: ByteArray,
|
||||
epoch: Long,
|
||||
): String {
|
||||
if (entries.size > MAX_ENTRIES) throw PinListTooLargeException("pin list exceeds $MAX_ENTRIES entries")
|
||||
val sealed = Nip44.v2.encrypt(listJson(entries), conversationKey).encodePayload()
|
||||
val content = json.encodeToString(JsonObject.serializer(), JsonObject(mapOf("epoch" to JsonPrimitive(epoch.toString()), "sealed" to JsonPrimitive(sealed))))
|
||||
return checkCaps(entries.size, content)
|
||||
}
|
||||
|
||||
// ---- deletion --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* True when a kind-5 delete by [deleteAuthor] with [deleteTags] kills [pin]: self-erasure
|
||||
* outranks curation, so only the pin's proven author can, by naming its recomputed rumor id.
|
||||
*/
|
||||
fun killedBy(
|
||||
pin: VerifiedPin,
|
||||
deleteAuthor: HexKey,
|
||||
deleteTags: Array<Array<String>>,
|
||||
): Boolean = deleteAuthor == pin.author && deleteTags.any { it.size >= 2 && it[0] == "e" && it[1] == pin.rumorId }
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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.pins
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
|
||||
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20
|
||||
import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf
|
||||
import com.vitorpamplona.quartz.utils.equalsConstantTime
|
||||
|
||||
/**
|
||||
* NIP-44 v2 per-message key disclosure — the primitive a Concord Pin is built on (CORD-04 §7).
|
||||
*
|
||||
* NIP-44 derives each message's keys as `hkdf-expand(conversation_key, nonce, 76)` =
|
||||
* `chacha_key[32] ‖ chacha_nonce[12] ‖ hmac_key[32]`. The expansion is one-way, so disclosing it
|
||||
* opens exactly that one message: not the conversation key, not the epoch, not the author's other
|
||||
* traffic. Wire form: 76 bytes as 152 lowercase hex characters.
|
||||
*/
|
||||
object PinKeyDisclosure {
|
||||
const val MESSAGE_KEYS_BYTES = 76
|
||||
|
||||
private val HEX = Regex("^[0-9a-f]{152}$")
|
||||
private val chaCha = ChaCha20()
|
||||
|
||||
/** The 76-byte lowercase-hex wire form of [keys]. */
|
||||
fun encode(keys: Hkdf.MessageKey): String = (keys.chachaKey + keys.chachaNonce + keys.hmacKey).toHexKey()
|
||||
|
||||
/** Parses a disclosure; null unless it is exactly 152 lowercase hex characters. */
|
||||
fun decode(hex: String): Hkdf.MessageKey? {
|
||||
if (!HEX.matches(hex)) return null
|
||||
val bytes = hex.hexToByteArrayOrNull() ?: return null
|
||||
return Hkdf.MessageKey(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 44), bytes.copyOfRange(44, 76))
|
||||
}
|
||||
|
||||
private fun decodePayload(payload: String): Nip44v2.EncryptedInfo? =
|
||||
try {
|
||||
Nip44v2.EncryptedInfo.decodePayload(payload)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** The disclosure for one NIP-44 [payload], derived with the stream's [conversationKey]; null if the payload is malformed. */
|
||||
fun discloseFor(
|
||||
payload: String,
|
||||
conversationKey: ByteArray,
|
||||
): Hkdf.MessageKey? {
|
||||
val decoded = decodePayload(payload) ?: return null
|
||||
return Nip44.v2.getMessageKeys(conversationKey, decoded.nonce)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a NIP-44 v2 [payload] with disclosed [keys] (a pin's proof): MAC over nonce‖ciphertext
|
||||
* with the disclosed HMAC key, then ChaCha20, then a strict unpad. Null on any failure. Only the
|
||||
* standard u16-prefixed form is accepted — a Concord payload never exceeds 65,535 bytes.
|
||||
*/
|
||||
fun decryptWith(
|
||||
payload: String,
|
||||
keys: Hkdf.MessageKey,
|
||||
): String? {
|
||||
val decoded = decodePayload(payload) ?: return null
|
||||
val mac =
|
||||
try {
|
||||
Nip44.v2.hmacAad(keys.hmacKey, decoded.ciphertext, decoded.nonce)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
if (!mac.equalsConstantTime(decoded.mac)) return null
|
||||
val padded = chaCha.decrypt(decoded.ciphertext, keys.chachaNonce, keys.chachaKey)
|
||||
if (padded.size < 2) return null
|
||||
val len = (padded[0].toInt() and 0xFF shl 8) or (padded[1].toInt() and 0xFF)
|
||||
if (len < 1) return null
|
||||
if (padded.size.toLong() != 2 + Nip44.v2.calcPaddedLen(len)) return null
|
||||
return try {
|
||||
padded.decodeToString(2, 2 + len, throwOnInvalidSequence = true)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -83,6 +83,7 @@ object ConcordDirectInvite {
|
||||
if (seal !is SealEvent) return null
|
||||
val rumor = seal.unsealOrNull(recipientSigner) ?: return null
|
||||
if (rumor.kind != KIND) return null
|
||||
return ConcordJson.decodeOrNull<CommunityInvite>(rumor.content)
|
||||
// Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply").
|
||||
return ConcordJson.decodeOrNull<CommunityInvite>(rumor.content)?.let { ConcordInviteBundle.bound(it) }
|
||||
}
|
||||
}
|
||||
|
||||
+68
-2
@@ -26,9 +26,11 @@ import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
@@ -94,6 +96,52 @@ class MintedInviteLink(
|
||||
object ConcordInviteBundle {
|
||||
const val KIND = ConcordInviteBundleEvent.KIND
|
||||
|
||||
/**
|
||||
* A bundle naming more Channels than this is refused before anything is allocated for it
|
||||
* (CORD-05 §1: "reject a bundle carrying more than a sane channel count (Vector's ceiling is
|
||||
* 256)"). A bundle is attacker-crafted input reached by following a link.
|
||||
*/
|
||||
const val MAX_BUNDLE_CHANNELS = 256
|
||||
|
||||
/**
|
||||
* A bundle's `relays` are truncated to the Community's relay cap before anything connects to
|
||||
* them (CORD-05 §1, CORD-02 §6's "up to 5"): a hostile link must not be a connect storm.
|
||||
*/
|
||||
const val MAX_COMMUNITY_RELAYS = 5
|
||||
|
||||
/**
|
||||
* Bounds an attacker-crafted [invite] (CORD-05 §1 MUST): null when it names more than
|
||||
* [MAX_BUNDLE_CHANNELS] Channels, otherwise the invite with `relays` de-duplicated and
|
||||
* truncated to [MAX_COMMUNITY_RELAYS]. Every redeem path — link bundle, Direct Invite —
|
||||
* goes through [validate], which applies this.
|
||||
*/
|
||||
fun bound(invite: CommunityInvite): CommunityInvite? {
|
||||
if (invite.channels.size > MAX_BUNDLE_CHANNELS) return null
|
||||
val relays =
|
||||
invite.relays
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.take(MAX_COMMUNITY_RELAYS)
|
||||
return if (relays == invite.relays) invite else invite.copy(relays = relays)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [event] is really the bundle coordinate `(33301, linkSigner, d="")` (CORD-05 §2):
|
||||
* the right kind, authored by [linkSignerPubKey], an empty `d`, and a valid signature. A relay
|
||||
* filter is a hint, not a proof — a relay (or anyone who can write to one) could otherwise
|
||||
* serve a forged newer `vsk 9` and revoke a link it never owned.
|
||||
*/
|
||||
fun isAtCoordinate(
|
||||
event: Event,
|
||||
linkSignerPubKey: HexKey,
|
||||
): Boolean {
|
||||
if (event.kind != KIND) return false
|
||||
if (!event.pubKey.equals(linkSignerPubKey, ignoreCase = true)) return false
|
||||
val d = event.tags.firstOrNull { it.isNotEmpty() && it[0] == "d" }?.getOrNull(1) ?: ""
|
||||
if (d != "") return false
|
||||
return event.verify()
|
||||
}
|
||||
|
||||
private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite)
|
||||
|
||||
/** Builds a kind-33301 bundle event carrying [invite], encrypted under [token] and signed by [linkSignerPrivKey]. */
|
||||
@@ -125,7 +173,10 @@ object ConcordInviteBundle {
|
||||
createdAt: Long,
|
||||
): Event = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)).sign(ConcordInviteBundleEvent.buildRevocation(createdAt))
|
||||
|
||||
/** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */
|
||||
/**
|
||||
* Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle.
|
||||
* The result is already [bound]ed (CORD-05 §1), so an over-long relay list never reaches a caller.
|
||||
*/
|
||||
fun parse(
|
||||
event: Event,
|
||||
token: ByteArray,
|
||||
@@ -133,7 +184,7 @@ object ConcordInviteBundle {
|
||||
if (event.kind != KIND) return null
|
||||
return try {
|
||||
val bundleKey = ConcordKeyDerivation.inviteBundleKey(token)
|
||||
ConcordJson.decodeOrNull<CommunityInvite>(Nip44.v2.decrypt(event.content, bundleKey))
|
||||
ConcordJson.decodeOrNull<CommunityInvite>(Nip44.v2.decrypt(event.content, bundleKey))?.let { bound(it) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -152,11 +203,25 @@ object ConcordInviteBundle {
|
||||
* milliseconds) resolves to [InviteBundleStatus.Expired] rather than
|
||||
* [InviteBundleStatus.Live], so the expiry is actually enforced at the one place
|
||||
* every redeeming client already funnels through.
|
||||
*
|
||||
* Only events really at the coordinate count ([isAtCoordinate]: kind, author ==
|
||||
* [linkSignerPubKey], `d == ""`, valid signature) — the relay filter is not trusted, so a
|
||||
* forged newer revocation cannot kill a link, nor a forged bundle hijack one.
|
||||
*/
|
||||
fun classify(
|
||||
wraps: List<Event>,
|
||||
linkSignerPubKey: HexKey,
|
||||
token: ByteArray,
|
||||
nowMs: Long = TimeUtils.nowMillis(),
|
||||
): InviteBundleStatus {
|
||||
val genuine = wraps.filter { isAtCoordinate(it, linkSignerPubKey) }
|
||||
return classifyGenuine(genuine, token, nowMs)
|
||||
}
|
||||
|
||||
private fun classifyGenuine(
|
||||
wraps: List<Event>,
|
||||
token: ByteArray,
|
||||
nowMs: Long,
|
||||
): InviteBundleStatus {
|
||||
val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent
|
||||
if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked
|
||||
@@ -197,6 +262,7 @@ object ConcordInviteBundle {
|
||||
* member. Raise with the Concord/Armada authors before diverging.
|
||||
*/
|
||||
fun validate(invite: CommunityInvite): Boolean {
|
||||
if (invite.channels.size > MAX_BUNDLE_CHANNELS) return false
|
||||
val owner = invite.owner.hexToByteArrayOrNull() ?: return false
|
||||
val salt = invite.ownerSalt.hexToByteArrayOrNull() ?: return false
|
||||
return ConcordKeyDerivation.communityId(owner, salt).toHexKey() == invite.communityId
|
||||
|
||||
+22
-14
@@ -62,6 +62,13 @@ object ConcordInviteLink {
|
||||
const val VERSION = 4
|
||||
const val FLAG_STOCK_RELAYS = 0x01
|
||||
|
||||
/**
|
||||
* The fragment carries at most this many bootstrap relays (CORD-05 §3): it only has to *find*
|
||||
* the bundle, which then carries the Community's authoritative relay set. The reference
|
||||
* client's decoder refuses a longer list, so an encoder must never emit one.
|
||||
*/
|
||||
const val MAX_BOOTSTRAP_RELAYS = 3
|
||||
|
||||
private const val MARKER_WSS_HOST = 0
|
||||
private const val MARKER_FULL_URL = 255
|
||||
private const val WSS_PREFIX = "wss://"
|
||||
@@ -69,13 +76,10 @@ object ConcordInviteLink {
|
||||
|
||||
/**
|
||||
* Encodes the fragment for [token] and optional [relays]. Passing null or the
|
||||
* exact stock set uses flag `0x01` and emits no relay bytes; otherwise every
|
||||
* relay is encoded (dictionary id, `wss://` host, or full URL).
|
||||
*
|
||||
* The only ceiling is the format's own: the relay count is a single byte, so at
|
||||
* most 255 relays fit. Each carries its own byte cost, so a long list makes a long
|
||||
* link — pick relays that can actually serve the bundle rather than pasting a
|
||||
* whole relay list in.
|
||||
* exact stock set uses flag `0x01` and emits no relay bytes (the stock set is
|
||||
* flag-selected, so exempt from the cap); otherwise the first
|
||||
* [MAX_BOOTSTRAP_RELAYS] relays are encoded (dictionary id, `wss://` host, or full
|
||||
* URL) and the rest dropped (CORD-05 §3) — the bundle carries the full set.
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
fun encodeFragment(
|
||||
@@ -90,10 +94,10 @@ object ConcordInviteLink {
|
||||
if (useStock) {
|
||||
out.add(FLAG_STOCK_RELAYS.toByte())
|
||||
} else {
|
||||
require(relays.size <= 255) { "relay count must fit in one byte, was ${relays.size}" }
|
||||
val bounded = relays.distinct().take(MAX_BOOTSTRAP_RELAYS)
|
||||
out.add(0)
|
||||
out.add(relays.size.toByte())
|
||||
for (r in relays) {
|
||||
out.add(bounded.size.toByte())
|
||||
for (r in bounded) {
|
||||
val id = InviteRelayDictionary.idOf(r)
|
||||
when {
|
||||
id != null -> out.add(id.toByte())
|
||||
@@ -119,8 +123,9 @@ object ConcordInviteLink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes an invite [fragment]. Throws for a malformed fragment or a version
|
||||
* other than [VERSION] (lower = legacy, higher = newer than this client).
|
||||
* Decodes an invite [fragment]. Throws for a malformed fragment, a version
|
||||
* other than [VERSION] (lower = legacy, higher = newer than this client), or more
|
||||
* than [MAX_BOOTSTRAP_RELAYS] relays (CORD-05 §3, as the reference client does).
|
||||
* Unknown dictionary ids are skipped rather than aborting the parse.
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
@@ -138,7 +143,9 @@ object ConcordInviteLink {
|
||||
relays.addAll(InviteRelayDictionary.STOCK)
|
||||
usedStock = true
|
||||
} else {
|
||||
require(pos < bytes.size) { "fragment truncated" }
|
||||
val count = bytes[pos++].toInt() and 0xFF
|
||||
require(count <= MAX_BOOTSTRAP_RELAYS) { "too many bootstrap relays ($count, cap $MAX_BOOTSTRAP_RELAYS)" }
|
||||
repeat(count) {
|
||||
val marker = bytes[pos++].toInt() and 0xFF
|
||||
when (marker) {
|
||||
@@ -162,8 +169,9 @@ object ConcordInviteLink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a full shareable invite URL under [base], carrying every relay in [relays]
|
||||
* as the bootstrap set (or the stock flag when null / exactly the stock set).
|
||||
* Builds a full shareable invite URL under [base], carrying the first
|
||||
* [MAX_BOOTSTRAP_RELAYS] of [relays] as the bootstrap set (or the stock flag when
|
||||
* null / exactly the stock set).
|
||||
*/
|
||||
fun buildUrl(
|
||||
base: String,
|
||||
|
||||
+36
-14
@@ -33,6 +33,7 @@ import kotlinx.serialization.descriptors.elementNames
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonTransformingSerializer
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -86,12 +87,16 @@ class ConcordInviteListTombstone(
|
||||
* a newer schema. They are carried verbatim rather than dropped (re-encoding without them would
|
||||
* delete somebody's `signer_sk`) and rather than failing the whole read (which would refuse every
|
||||
* future mint and revoke for this account until someone else repaired the list).
|
||||
*
|
||||
* [opaqueTombstones] is the same for tombstones: one that does not type-check is residue we carry
|
||||
* verbatim, never drop — dropping a retirement is how a stale device resurrects a revoked link.
|
||||
*/
|
||||
class ConcordInviteListDocument(
|
||||
val entries: List<ConcordInviteListEntry> = emptyList(),
|
||||
val tombstones: List<ConcordInviteListTombstone> = emptyList(),
|
||||
val residue: JsonObject = NoExtras,
|
||||
val opaqueEntries: List<JsonObject> = emptyList(),
|
||||
val opaqueTombstones: List<JsonElement> = emptyList(),
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = ConcordInviteListDocument()
|
||||
@@ -201,14 +206,16 @@ object ConcordInviteList {
|
||||
}
|
||||
}
|
||||
|
||||
val opaqueTombstones = mutableListOf<JsonElement>()
|
||||
val tombstones =
|
||||
(root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element ->
|
||||
try {
|
||||
val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject)
|
||||
ConcordInviteListTombstone(it.token, it.communityId, it.extras)
|
||||
} catch (_: Exception) {
|
||||
// A tombstone we cannot read must not silently un-retire its link, but we
|
||||
// have no token to key it by, so it can only ride along as document residue.
|
||||
// A tombstone we cannot type must not silently un-retire its link: carry it
|
||||
// verbatim as residue (and still honor its token in the merge, if it has one).
|
||||
opaqueTombstones.add(element)
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -218,6 +225,7 @@ object ConcordInviteList {
|
||||
tombstones = tombstones,
|
||||
residue = JsonObject(root - "entries" - "tombstones"),
|
||||
opaqueEntries = opaque,
|
||||
opaqueTombstones = opaqueTombstones,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
@@ -238,30 +246,43 @@ object ConcordInviteList {
|
||||
),
|
||||
).jsonObject
|
||||
|
||||
// Entries we could not type ride back out untouched. Dropping them here is the data loss
|
||||
// this whole class exists to prevent — they are somebody's link signer too.
|
||||
if (doc.opaqueEntries.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire)
|
||||
val entries = JsonArray((wire["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries)
|
||||
return ConcordJson.instance.encodeToString(JsonObject.serializer(), JsonObject(wire + ("entries" to entries)))
|
||||
// Entries and tombstones we could not type ride back out untouched. Dropping them here is
|
||||
// the data loss this whole class exists to prevent — a link signer, or a link's retirement.
|
||||
if (doc.opaqueEntries.isEmpty() && doc.opaqueTombstones.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire)
|
||||
var out = wire
|
||||
if (doc.opaqueEntries.isNotEmpty()) {
|
||||
out = JsonObject(out + ("entries" to JsonArray((out["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries)))
|
||||
}
|
||||
if (doc.opaqueTombstones.isNotEmpty()) {
|
||||
out = JsonObject(out + ("tombstones" to JsonArray((out["tombstones"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueTombstones)))
|
||||
}
|
||||
return ConcordJson.instance.encodeToString(JsonObject.serializer(), out)
|
||||
}
|
||||
|
||||
/** The `token` an untyped tombstone still names, if it names one as a string. */
|
||||
private fun opaqueTombstoneToken(element: JsonElement): String? = ((element as? JsonObject)?.get("token") as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
|
||||
/**
|
||||
* Merges [patch] onto [base], keyed by `token` — the spec's own merge key. A token present in
|
||||
* either side's tombstones is dropped from the result and kept tombstoned, so a retired link
|
||||
* cannot be resurrected by a device that still has it cached. [patch] wins field-by-field on a
|
||||
* token both sides carry, which is what makes "read remote, apply my change, publish" converge.
|
||||
* Merges [patch] onto [base], keyed by `token` — the spec's own merge key (CORD-05 §4). An entry
|
||||
* is **immutable once minted**, so the first copy of a token wins ([base], the published list,
|
||||
* before [patch]) and a later copy can never rewrite its `signer_sk` or url; tombstones union
|
||||
* (first wins likewise), and a tombstone always beats an entry — terminally, so a retired link
|
||||
* cannot be resurrected by a device that still has it cached. Mirrors the reference client's
|
||||
* `mergeInviteLists`. A tombstone we could not type still retires the token it names.
|
||||
*/
|
||||
fun merge(
|
||||
base: ConcordInviteListDocument,
|
||||
patch: ConcordInviteListDocument,
|
||||
): ConcordInviteListDocument {
|
||||
val tombstones = LinkedHashMap<String, ConcordInviteListTombstone>()
|
||||
for (t in base.tombstones + patch.tombstones) tombstones[t.token] = t
|
||||
for (t in base.tombstones + patch.tombstones) tombstones.getOrPut(t.token) { t }
|
||||
val opaqueTombstones = (base.opaqueTombstones + patch.opaqueTombstones).distinct()
|
||||
val retired = tombstones.keys + opaqueTombstones.mapNotNull { opaqueTombstoneToken(it) }
|
||||
|
||||
val entries = LinkedHashMap<String, ConcordInviteListEntry>()
|
||||
for (e in base.entries + patch.entries) {
|
||||
if (e.token in tombstones) continue
|
||||
entries[e.token] = e
|
||||
if (e.token in retired) continue
|
||||
entries.getOrPut(e.token) { e }
|
||||
}
|
||||
return ConcordInviteListDocument(
|
||||
entries = entries.values.toList(),
|
||||
@@ -270,6 +291,7 @@ object ConcordInviteList {
|
||||
// Untyped entries survive the merge for the same reason they survive a decode: we cannot
|
||||
// read them, so we are in no position to decide they are disposable.
|
||||
opaqueEntries = (base.opaqueEntries + patch.opaqueEntries).distinct(),
|
||||
opaqueTombstones = opaqueTombstones,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+32
-38
@@ -24,38 +24,37 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
|
||||
/**
|
||||
* Stranded recovery (CORD-05/06).
|
||||
* Stranded detection (CORD-05/06).
|
||||
*
|
||||
* A Refounding carries only `(newRoot, newEpoch, rotator)` — there is **no
|
||||
* recipient list** — so a member who is simply left out of the rekey recipient
|
||||
* set receives nothing and is silently stranded on the dead epoch forever, while
|
||||
* everyone else moves on. This is true of any member, the owner included, and
|
||||
* cannot be prevented on the receive side.
|
||||
* set receives nothing and is silently stranded on the dead epoch, while everyone
|
||||
* else moves on. The invite link the membership was joined through
|
||||
* ([ConcordCommunityListEntry.inviteRef]) is re-minted at the current epoch by its
|
||||
* creator, so re-resolving it and finding a **higher** epoch tells a member they
|
||||
* were left behind.
|
||||
*
|
||||
* The way out is the invite link the membership was joined through
|
||||
* ([ConcordCommunityListEntry.inviteRef]): the community keeps publishing its
|
||||
* bundle at that same addressable coordinate, re-minted at the current epoch. So
|
||||
* a member who re-resolves their own join link and finds a **higher** epoch than
|
||||
* the one they hold knows they were left behind, and can merge forward.
|
||||
*
|
||||
* This object holds only the pure decision + merge; fetching and unlocking the
|
||||
* bundle at the link is the caller's job.
|
||||
* What a bundle may NOT do is move the base (CORD-06 §2, and the reference client's
|
||||
* `isCatchUpBundle`: "It may never move the base"). Nothing binds `community_root`
|
||||
* to `community_id`, so a bundle is not proof of continuity: a link creator — or
|
||||
* anyone who ever held a link's signer — could serve a higher-epoch bundle carrying
|
||||
* a root of their own and silently relocate every member who joined through that
|
||||
* link onto streams they read. The base advances only by a CORD-06 §2 rekey blob
|
||||
* whose `prevcommit` proves it extends the key we hold, from a rotator our roster
|
||||
* authorizes. So this object only **detects** ([isStranded]); the background sweep
|
||||
* never adopts anything, and the one way forward from a bundle is the user
|
||||
* explicitly accepting the link again ([rejoinForward]) — the same trust decision
|
||||
* as the first join, never taken on their behalf.
|
||||
*/
|
||||
object ConcordStrandedRecovery {
|
||||
/**
|
||||
* True when [bundle], resolved at [entry]'s stored invite link, proves we were
|
||||
* True when [bundle], resolved at [entry]'s stored invite link, says we were
|
||||
* left behind: it must describe the same community and sit at a strictly higher
|
||||
* epoch. Same or lower is a no-op (we are current, or the bundle is stale).
|
||||
*
|
||||
* [bannedAtCurrentEpoch] is the caller's answer to "does the community, as I fold
|
||||
* it right now, have me on its banlist?" — and a `true` refuses the recovery
|
||||
* outright. It is a required argument rather than a caller-side `if` because
|
||||
* getting it wrong turns this mechanism inside out: recovery exists so a member
|
||||
* *wrongly* omitted from a rotation can catch up, but the test it performs (a
|
||||
* higher epoch at a link whose unlock token an ex-member keeps forever) cannot
|
||||
* tell that member apart from one the community deliberately removed. Without
|
||||
* this, a Refounding — the only hard removal Concord has — is undone by our own
|
||||
* background sweep a few minutes later.
|
||||
* it right now, have me on its banlist?" — and a `true` answers false outright:
|
||||
* a removed member is not stranded, they are removed.
|
||||
*/
|
||||
fun isStranded(
|
||||
entry: ConcordCommunityListEntry,
|
||||
@@ -68,21 +67,18 @@ object ConcordStrandedRecovery {
|
||||
bundle.rootEpoch > entry.rootEpoch
|
||||
|
||||
/**
|
||||
* Merges [entry] forward onto the higher-epoch [bundle], or returns null when
|
||||
* there is nothing to do ([isStranded] is false) — so the caller can treat null
|
||||
* as "stay put" without a second check.
|
||||
* The entry that results from the user **explicitly** re-accepting the invite
|
||||
* link [bundle] was resolved from, while stranded on [entry] — or null when
|
||||
* [isStranded] is false. Never call this from a background sweep: adopting a
|
||||
* bundle's root is a join decision (see the class note), and only the user can
|
||||
* make it.
|
||||
*
|
||||
* The merge is epoch-monotonic (it never moves backwards, by construction of
|
||||
* [isStranded]) and preserves two things the naive "adopt the bundle" would
|
||||
* destroy:
|
||||
*
|
||||
* - the [ConcordCommunityListEntry.inviteRef] anchor, so the next Refounding we
|
||||
* are left out of is recoverable too; and
|
||||
* - the existing [ConcordCommunityListEntry.heldRoots], plus the root we are
|
||||
* leaving, so prior-epoch history the member legitimately holds stays
|
||||
* derivable instead of going dark on catch-up.
|
||||
* The merge is epoch-monotonic and preserves what a fresh join would lose: the
|
||||
* [ConcordCommunityListEntry.inviteRef] anchor, and the existing
|
||||
* [ConcordCommunityListEntry.heldRoots] plus the root we are leaving, so
|
||||
* prior-epoch history stays derivable.
|
||||
*/
|
||||
fun mergeForward(
|
||||
fun rejoinForward(
|
||||
entry: ConcordCommunityListEntry,
|
||||
bundle: CommunityInvite,
|
||||
bannedAtCurrentEpoch: Boolean,
|
||||
@@ -92,7 +88,7 @@ object ConcordStrandedRecovery {
|
||||
// Bank the epoch we are leaving with its control_pk, so its Control Plane
|
||||
// stays re-subscribable for the anti-rollback floor (a split epoch's address
|
||||
// is held, never derivable — CORD-02 §2).
|
||||
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }
|
||||
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch to it.key.lowercase() }
|
||||
|
||||
return ConcordCommunityListEntry(
|
||||
id = entry.id,
|
||||
@@ -109,10 +105,8 @@ object ConcordStrandedRecovery {
|
||||
name = entry.name.ifEmpty { bundle.name },
|
||||
addedAt = entry.addedAt,
|
||||
inviteRef = entry.inviteRef,
|
||||
// We were excluded from the epoch we were sitting on when we found the gap.
|
||||
excludedAtEpoch = entry.rootEpoch,
|
||||
// Unknown keys another client wrote are data we hold in trust: carry them forward,
|
||||
// or this recovery write silently deletes them.
|
||||
// or this write silently deletes them.
|
||||
residue = entry.residue,
|
||||
)
|
||||
}
|
||||
|
||||
+254
-36
@@ -21,7 +21,10 @@
|
||||
package com.vitorpamplona.quartz.concord.cord06Rekey
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
@@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
/**
|
||||
* The events a Refounding produces (CORD-06 §3): the [controlWraps] (the current
|
||||
@@ -74,11 +78,51 @@ class ReceivedRefounding(
|
||||
val rotator: HexKey,
|
||||
val newControlPk: ByteArray? = null,
|
||||
val newControlRoot: ByteArray? = null,
|
||||
/**
|
||||
* The Grant the rotator claims to act under (`vac`, CORD-06 §3 "Authority"), null when absent
|
||||
* (the owner cites nothing). The caller verifies it against its fold before adopting — see
|
||||
* [ConcordRotationAuthority.citationSatisfied].
|
||||
*/
|
||||
val authority: AuthorityCitation? = null,
|
||||
) {
|
||||
/** True when this was a legacy pre-split rotation (72-byte base blob). */
|
||||
val legacy: Boolean get() = newControlPk == null
|
||||
}
|
||||
|
||||
/**
|
||||
* A Refounding the rotator has already minted keys for (CORD-06 §3 "Failure and races"): the
|
||||
* fresh [newRoot] + [newControlRoot] reserved for the rotation from ([rootEpoch], [prevCommit]).
|
||||
* A retry of the same rotation MUST reuse them — rotations correlate by (rotator, newepoch,
|
||||
* prevcommit), so a retry with a fresh root would merge into the first attempt's set and split
|
||||
* the members across two roots at one epoch. Keep it until the rotation is adopted, then drop it.
|
||||
*/
|
||||
class PendingRefounding(
|
||||
val communityId: HexKey,
|
||||
val rootEpoch: Long,
|
||||
val prevCommit: HexKey,
|
||||
val newRoot: ByteArray,
|
||||
val newControlRoot: ByteArray,
|
||||
) {
|
||||
/** True when this reservation is for the rotation that leaves [priorRoot] at [rootEpoch]. */
|
||||
fun matches(
|
||||
communityId: HexKey,
|
||||
rootEpoch: Long,
|
||||
priorRoot: ByteArray,
|
||||
): Boolean =
|
||||
this.communityId.equals(communityId, ignoreCase = true) &&
|
||||
this.rootEpoch == rootEpoch &&
|
||||
prevCommit == ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a Refounding cannot carry the whole Control Plane into the new epoch (CORD-06 §3:
|
||||
* "If the Refounder cannot reliably fold all Control events, the Refounding must be aborted").
|
||||
* [missing] names the entities (`eid` hex) whose honored head is not in the compaction.
|
||||
*/
|
||||
class IncompleteControlPlaneException(
|
||||
val missing: List<String>,
|
||||
) : IllegalStateException("Refounding aborted: the Control Plane could not be folded in full (${missing.size} entity head(s) missing)")
|
||||
|
||||
/**
|
||||
* Whole-community Refounding (CORD-06 §3): rotate `community_root` to sever a
|
||||
* removed member absolutely. Public Channels and the Control/Guestbook planes all
|
||||
@@ -111,6 +155,11 @@ object ConcordRefounding {
|
||||
* @param recipientsXOnly the retained members' x-only pubkeys (hex) to re-key
|
||||
* @param staffXOnly the subset of [recipientsXOnly] that is staff (owner + Control-writing
|
||||
* permission holders, CORD-04 §3) and receives the 136-byte blob
|
||||
* @param authority the rotator's `vac` citation (CORD-06 §3 "Authority"), stamped on every
|
||||
* rekey chunk; null when the owner rotates
|
||||
* @param mustCarry the entity heads (`eid` hex -> version) the rotator currently honors; the
|
||||
* compaction must carry each at or above that version, or the Refounding
|
||||
* aborts with [IncompleteControlPlaneException] (CORD-06 §3)
|
||||
*/
|
||||
suspend fun build(
|
||||
rotatorSigner: NostrSigner,
|
||||
@@ -125,11 +174,15 @@ object ConcordRefounding {
|
||||
staffXOnly: Set<HexKey>,
|
||||
createdAt: Long,
|
||||
ownerPubKey: HexKey,
|
||||
authority: AuthorityCitation? = null,
|
||||
mustCarry: Map<String, Long> = emptyMap(),
|
||||
): RefoundingBuild {
|
||||
val newEpoch = rootEpoch + 1
|
||||
val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot)
|
||||
|
||||
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, ownerPubKey)
|
||||
// Acquired in full BEFORE anything is published (CORD-06 §3): throws when the plane cannot be
|
||||
// carried whole, so a failed fold never leaves a half rotation as the only copy.
|
||||
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, communityId, ownerPubKey, mustCarry)
|
||||
|
||||
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch)
|
||||
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
|
||||
@@ -146,6 +199,7 @@ object ConcordRefounding {
|
||||
prevEpoch = rootEpoch,
|
||||
prevCommit = prevCommit,
|
||||
createdAt = createdAt,
|
||||
authority = authority,
|
||||
)
|
||||
|
||||
return RefoundingBuild(newRoot, newControlRoot, newEpoch, newControlKeys, controlWraps, rekeyWraps)
|
||||
@@ -163,19 +217,28 @@ object ConcordRefounding {
|
||||
* must hold the new signer. A Rotator MUST NOT mirror editions to the new
|
||||
* epoch's legacy-derived address to appease stale readers — the mirror
|
||||
* re-opens exactly the member-writable surface the split closes.
|
||||
*
|
||||
* Only plaintext-sealed editions are carried ([ControlEdition.fromOpened]): an
|
||||
* encrypted-seal edition is not a Control edition (CORD-02 §5), and re-wrapping one
|
||||
* would republish it under a signature over ciphertext no reader can keep. Heads of
|
||||
* sub-kinds this client does not model (Pins, Signals, anything newer) ride through
|
||||
* verbatim like every other head, so our Refounding never erases another client's state
|
||||
* (CORD-06 §3: the compaction re-wraps each entity's current head).
|
||||
*/
|
||||
fun compactControlPlane(
|
||||
priorWraps: List<Event>,
|
||||
priorControlKeys: ControlPlaneKeys,
|
||||
newControlKeys: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
ownerPubKey: HexKey,
|
||||
mustCarry: Map<String, Long> = emptyMap(),
|
||||
): List<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 edition = ControlEdition.fromOpened(opened) ?: continue
|
||||
val coord = edition.vsk + ":" + edition.entityIdHex
|
||||
byCoordinate.getOrPut(coord) { ArrayList() }.add(edition to opened.seal)
|
||||
}
|
||||
|
||||
@@ -195,21 +258,48 @@ object ConcordRefounding {
|
||||
// unprivileged author, and it is exactly what ConcordCommunityState.fold would seat, so the
|
||||
// compacted epoch starts where the previous one left off.
|
||||
val editions = byCoordinate.values.flatten()
|
||||
val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, ownerPubKey)
|
||||
val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, communityId, ownerPubKey)
|
||||
val out = ArrayList<Event>(honored.size)
|
||||
for ((_, floor) in honored) {
|
||||
val head = floor.known ?: continue
|
||||
val seal = editions.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue
|
||||
val missing = ArrayList<String>()
|
||||
for ((entity, floor) in honored) {
|
||||
val head = floor.known
|
||||
val seal = head?.let { h -> editions.firstOrNull { it.first.rumorId == h.rumorId }?.second }
|
||||
if (seal == null) {
|
||||
// A head we honor whose signed seal we cannot re-wrap would silently drop the entity
|
||||
// from the new epoch: abort instead (fold-all-or-abort, CORD-06 §3).
|
||||
missing.add(entity)
|
||||
continue
|
||||
}
|
||||
out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt))
|
||||
}
|
||||
// Every head the caller already folds must survive at or above the version it honors: a
|
||||
// shorter compaction means the fetch we compacted from was partial, and publishing it would
|
||||
// roll the community back for every member who follows the new epoch.
|
||||
for ((entity, version) in mustCarry) {
|
||||
val carried = honored[entity]?.known?.version
|
||||
if (carried == null || carried < version) missing.add(entity)
|
||||
}
|
||||
if (missing.isNotEmpty()) throw IncompleteControlPlaneException(missing.distinct())
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The version of every entity head [editions] honor (`eid` hex -> version) — what a
|
||||
* Refounder passes as `mustCarry`, so the compaction aborts rather than drop a head the
|
||||
* Refounder itself folds (CORD-06 §3).
|
||||
*/
|
||||
fun headVersions(
|
||||
editions: Collection<ControlEdition>,
|
||||
communityId: ByteArray,
|
||||
ownerPubKey: HexKey,
|
||||
): Map<String, Long> = ConcordCommunityState.authorizedHeads(editions, communityId, ownerPubKey).mapValues { it.value.version }
|
||||
|
||||
/**
|
||||
* Mints the base-rotation rekey blobs delivering [newRoot] + [newControlPk] to
|
||||
* [recipientsXOnly] — the [staffXOnly] subset also receiving [newControlRoot]
|
||||
* in the 136-byte staff form (CORD-06 §1) — chunked at
|
||||
* [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal,
|
||||
* in the 136-byte staff form (CORD-06 §1) — chunked by count and bytes
|
||||
* ([ConcordRekey.chunkBlobs], 1-based `chunk` tags, [authority] cited on every
|
||||
* chunk) and wrapped (encrypted seal,
|
||||
* rotator-signed) on the [baseRekeyKey] address so every current member — who
|
||||
* precomputes that address from the prior root — receives it live.
|
||||
*/
|
||||
@@ -225,6 +315,7 @@ object ConcordRefounding {
|
||||
prevEpoch: Long,
|
||||
prevCommit: HexKey,
|
||||
createdAt: Long,
|
||||
authority: AuthorityCitation? = null,
|
||||
): List<Event> {
|
||||
if (recipientsXOnly.isEmpty()) return emptyList()
|
||||
val staffLower = staffXOnly.mapTo(HashSet()) { it.lowercase() }
|
||||
@@ -240,10 +331,19 @@ object ConcordRefounding {
|
||||
newControlRoot = if (recipient.lowercase() in staffLower) newControlRoot else null,
|
||||
)
|
||||
}
|
||||
val chunks = blobs.chunked(ConcordRekey.MAX_BLOBS_PER_CHUNK)
|
||||
// The envelope is measured once at the widest `chunk` tag this rotation could carry.
|
||||
val widest = blobs.size.coerceAtLeast(1)
|
||||
val envelopeTags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, widest, widest, authority)
|
||||
val envelope =
|
||||
RumorAssembler
|
||||
.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, envelopeTags, "")
|
||||
.toJson()
|
||||
.encodeToByteArray()
|
||||
.size
|
||||
val chunks = ConcordRekey.chunkBlobs(blobs, envelope)
|
||||
val total = chunks.size
|
||||
return chunks.mapIndexed { index, chunk ->
|
||||
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index, total)
|
||||
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index + 1, total, authority)
|
||||
val rumor = RumorAssembler.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk))
|
||||
ConcordStreamEnvelope.wrap(rumor, baseRekeyKey, rotatorSigner, encrypted = true, createdAt = createdAt)
|
||||
}
|
||||
@@ -252,14 +352,25 @@ object ConcordRefounding {
|
||||
/**
|
||||
* Receives a base rotation for the member behind [recipientSigner]: opens the
|
||||
* kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]),
|
||||
* verifies each is a well-formed root rotation to [newEpoch] whose `prevcommit`
|
||||
* continues the [priorRoot] the member holds, and returns the delivered new
|
||||
* root and Control Plane keys (with the rotator's real pubkey, so the caller
|
||||
* can authorize it against the folded roster). A staff blob's delivered secret
|
||||
* must derive to exactly the delivered `control_pk` (CORD-02 §5) — a
|
||||
* mismatched pair is refused rather than adopting a plane split from its
|
||||
* readers. Null if no chunk carries this member's blob — which only means
|
||||
* "removed" once the caller confirms it holds every chunk of the rotation.
|
||||
* verifies each is a well-formed root rotation to `rootEpoch + 1` whose
|
||||
* `prevepoch`/`prevcommit` continue the [priorRoot] the member holds, and returns
|
||||
* the delivered new root and Control Plane keys with the rotator's real pubkey and
|
||||
* `vac` citation, so the caller can authorize it against the folded roster.
|
||||
*
|
||||
* A rekey rumor must ride an **encrypted** (20013) seal (CORD-02 §5, Appendix B); a
|
||||
* malformed `chunk` tag (0-based, `i > n`, non-decimal) or `vac` tag drops the
|
||||
* chunk, and a rotator whose chunks cite different Grants is distrusted whole. A
|
||||
* staff blob's delivered secret must derive to exactly the delivered `control_pk`
|
||||
* (CORD-02 §5) — a mismatched pair is refused rather than adopting a plane split
|
||||
* from its readers.
|
||||
*
|
||||
* Race convergence (CORD-06 §3): among the candidates [accept] admits (the
|
||||
* caller's authority check — authorize BEFORE converging, or an unauthorized
|
||||
* lower root would win), the lexicographically lowest new base key wins; the
|
||||
* control pair rides the winner's blob and is never compared.
|
||||
*
|
||||
* Null if no chunk carries this member's blob — which only means "removed" once
|
||||
* the caller confirms it holds every chunk of the rotation.
|
||||
*/
|
||||
suspend fun findNewRoot(
|
||||
wraps: List<Event>,
|
||||
@@ -268,31 +379,138 @@ object ConcordRefounding {
|
||||
communityId: ByteArray,
|
||||
priorRoot: ByteArray,
|
||||
rootEpoch: Long,
|
||||
): ReceivedRefounding? {
|
||||
accept: (ReceivedRefounding) -> Boolean = { true },
|
||||
): ReceivedRefounding? = converge(findNewRoots(wraps, baseRekeyKey, recipientSigner, communityId, priorRoot, rootEpoch).filter(accept))
|
||||
|
||||
/**
|
||||
* Every candidate rotation delivering this member a new root from [priorRoot] at
|
||||
* [rootEpoch] (one per rotator; see [findNewRoot] for the checks), unauthorized and
|
||||
* unconverged. Callers normally want [findNewRoot].
|
||||
*/
|
||||
suspend fun findNewRoots(
|
||||
wraps: List<Event>,
|
||||
baseRekeyKey: GroupKey,
|
||||
recipientSigner: NostrSigner,
|
||||
communityId: ByteArray,
|
||||
priorRoot: ByteArray,
|
||||
rootEpoch: Long,
|
||||
): List<ReceivedRefounding> {
|
||||
val newEpoch = rootEpoch + 1
|
||||
val expectedScope = ConcordRekey.ROOT_SCOPE.toHexKey()
|
||||
val expectedCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
|
||||
|
||||
// Pass 1: the well-formed chunks of this continuity point, grouped by rotator.
|
||||
val byRotator = LinkedHashMap<HexKey, MutableList<RekeyChunk>>()
|
||||
for (wrap in wraps) {
|
||||
val opened = ConcordStreamEnvelope.openOrNull(wrap, baseRekeyKey) ?: continue
|
||||
// Rekey seals are encrypted (CORD-02 §5): a plaintext seal would expose the rotator.
|
||||
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) continue
|
||||
val rumor = opened.rumor
|
||||
if (rumor.kind != ConcordRekey.KIND) continue
|
||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE) != expectedScope) continue
|
||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE)?.lowercase() != expectedScope) continue
|
||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)?.toLongOrNull() != newEpoch) continue
|
||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT) != expectedCommit) continue
|
||||
|
||||
val blobs = ConcordRekey.decodeContent(rumor.content)
|
||||
val rotatorXOnly = opened.author.hexToByteArray()
|
||||
val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue
|
||||
val controlRoot = payload.newControlRoot
|
||||
val controlPk = payload.newControlPk
|
||||
if (controlRoot != null && controlPk != null) {
|
||||
// The staff derive-check (CORD-06 §1): refuse a pair whose secret does not
|
||||
// derive to the pk the other members were handed — fails closed.
|
||||
val derived = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, newEpoch).publicKey
|
||||
if (!derived.contentEquals(controlPk)) continue
|
||||
}
|
||||
return ReceivedRefounding(payload.newKey, newEpoch, opened.author, controlPk, controlRoot)
|
||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVEPOCH)?.toLongOrNull() != rootEpoch) continue
|
||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT)?.lowercase() != expectedCommit) continue
|
||||
if (ConcordRekey.chunkOf(rumor.tags) == null) continue
|
||||
// A present-but-malformed citation is a corrupt chunk; absent means the owner acts.
|
||||
val vacTag = rumor.tags.firstOrNull { it.isNotEmpty() && it[0] == VacTag.TAG_NAME }
|
||||
val citation = if (vacTag == null) null else VacTag.parse(vacTag) ?: continue
|
||||
byRotator.getOrPut(opened.author.lowercase()) { ArrayList() }.add(RekeyChunk(opened.author, rumor.content, citation))
|
||||
}
|
||||
return null
|
||||
|
||||
// Pass 2: per rotator, find this member's blob.
|
||||
val out = ArrayList<ReceivedRefounding>()
|
||||
for ((_, chunks) in byRotator) {
|
||||
// Every chunk of one rotation must cite the same Grant; a disagreeing set is distrusted.
|
||||
val citations = chunks.map { c -> c.citation?.let { VacTag.assemble(it).joinToString(",") } }.distinct()
|
||||
if (citations.size > 1) continue
|
||||
val rotator = chunks.first().rotator
|
||||
val rotatorXOnly = rotator.hexToByteArray()
|
||||
for (chunk in chunks) {
|
||||
val blobs = ConcordRekey.decodeContent(chunk.content)
|
||||
val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue
|
||||
val controlRoot = payload.newControlRoot
|
||||
val controlPk = payload.newControlPk
|
||||
if (controlRoot != null && controlPk != null) {
|
||||
// The staff derive-check (CORD-06 §1): refuse a pair whose secret does not
|
||||
// derive to the pk the other members were handed — fails closed.
|
||||
val derived = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, newEpoch).publicKey
|
||||
if (!derived.contentEquals(controlPk)) continue
|
||||
}
|
||||
out.add(ReceivedRefounding(payload.newKey, newEpoch, rotator, controlPk, controlRoot, chunk.citation))
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private class RekeyChunk(
|
||||
val rotator: HexKey,
|
||||
val content: String,
|
||||
val citation: AuthorityCitation?,
|
||||
)
|
||||
|
||||
/**
|
||||
* The winner among authorized candidates racing to one epoch (CORD-06 §3): the
|
||||
* lexicographically lowest new base key. Every client computes the same winner, so
|
||||
* concurrent Refoundings converge; null on no candidates.
|
||||
*/
|
||||
fun converge(candidates: List<ReceivedRefounding>): ReceivedRefounding? = candidates.minWithOrNull { a, b -> compareKeys(a.newRoot, b.newRoot) }
|
||||
|
||||
/** Unsigned lexicographic order of two keys — the order the convergence rule compares in. */
|
||||
fun compareKeys(
|
||||
a: ByteArray,
|
||||
b: ByteArray,
|
||||
): Int {
|
||||
for (i in 0 until minOf(a.size, b.size)) {
|
||||
val x = a[i].toInt() and 0xFF
|
||||
val y = b[i].toInt() and 0xFF
|
||||
if (x != y) return x - y
|
||||
}
|
||||
return a.size - b.size
|
||||
}
|
||||
|
||||
/**
|
||||
* The down-only heal (CORD-06 §3): true when [candidate] should replace the [held] root
|
||||
* of the same epoch — only a **strictly lower** sibling does, so a flaky fetch that
|
||||
* returns only the higher sibling can never re-fork a settled epoch.
|
||||
*/
|
||||
fun healsTo(
|
||||
held: ByteArray,
|
||||
candidate: ByteArray,
|
||||
): Boolean = compareKeys(candidate, held) < 0
|
||||
|
||||
/**
|
||||
* The held roots a client folds Control from: per epoch, the lowest key — the one the
|
||||
* convergence rule settled on. A higher sibling at the same epoch is a losing fork's root,
|
||||
* kept only so the messages sent into that fork stay readable (CORD-06 §3: "Both forks'
|
||||
* keys are retained"); its Control Plane is not the community's.
|
||||
*/
|
||||
fun canonicalHeldRoots(heldRoots: List<HeldRoot>): List<HeldRoot> =
|
||||
heldRoots
|
||||
.groupBy { it.epoch }
|
||||
.values
|
||||
.mapNotNull { sameEpoch -> sameEpoch.minWithOrNull { a, b -> a.key.lowercase().compareTo(b.key.lowercase()) } }
|
||||
|
||||
/**
|
||||
* The keys for the Refounding that leaves [priorRoot] at [rootEpoch]: [pending] when it
|
||||
* was reserved for exactly this rotation (a retry — CORD-06 §3 requires every step to be
|
||||
* idempotent, so a retry must re-deliver the SAME root), a fresh pair otherwise. The
|
||||
* caller persists the result before publishing anything and drops it once adopted.
|
||||
*/
|
||||
fun reserveKeys(
|
||||
pending: PendingRefounding?,
|
||||
communityId: HexKey,
|
||||
rootEpoch: Long,
|
||||
priorRoot: ByteArray,
|
||||
): PendingRefounding {
|
||||
if (pending != null && pending.matches(communityId, rootEpoch, priorRoot)) return pending
|
||||
return PendingRefounding(
|
||||
communityId = communityId.lowercase(),
|
||||
rootEpoch = rootEpoch,
|
||||
prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey(),
|
||||
newRoot = RandomInstance.bytes(32),
|
||||
newControlRoot = RandomInstance.bytes(32),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+91
-9
@@ -20,7 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord06Rekey
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -85,7 +87,13 @@ object ConcordRekey {
|
||||
return RekeyBlob(locator, wrapped)
|
||||
}
|
||||
|
||||
/** The kind-3303 rumor tags for a rekey chunk. */
|
||||
/**
|
||||
* The kind-3303 rumor tags for a rekey chunk. [chunkIndex] is **1-based** (CORD-06 §2's
|
||||
* "chunk i of n"; the reference client refuses an index below 1, so a 0-based chunk makes the
|
||||
* whole rotation unreadable to it). [authority] is the rotator's `vac` citation (CORD-06 §3
|
||||
* "Authority", CORD-04 §5), carried on EVERY chunk so a partial holder can judge authority;
|
||||
* null when the owner rotates.
|
||||
*/
|
||||
fun tags(
|
||||
scopeId: ByteArray,
|
||||
newEpoch: Long,
|
||||
@@ -93,14 +101,78 @@ object ConcordRekey {
|
||||
prevCommit: HexKey,
|
||||
chunkIndex: Int,
|
||||
chunkTotal: Int,
|
||||
): Array<Array<String>> =
|
||||
arrayOf(
|
||||
arrayOf(TAG_SCOPE, scopeId.toHexKey()),
|
||||
arrayOf(TAG_NEWEPOCH, newEpoch.toString()),
|
||||
arrayOf(TAG_PREVEPOCH, prevEpoch.toString()),
|
||||
arrayOf(TAG_PREVCOMMIT, prevCommit),
|
||||
arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()),
|
||||
)
|
||||
authority: AuthorityCitation? = null,
|
||||
): Array<Array<String>> {
|
||||
require(chunkTotal >= 1 && chunkIndex in 1..chunkTotal) { "chunk must be 1..n, was $chunkIndex of $chunkTotal" }
|
||||
val base =
|
||||
arrayOf(
|
||||
arrayOf(TAG_SCOPE, scopeId.toHexKey()),
|
||||
arrayOf(TAG_NEWEPOCH, newEpoch.toString()),
|
||||
arrayOf(TAG_PREVEPOCH, prevEpoch.toString()),
|
||||
arrayOf(TAG_PREVCOMMIT, prevCommit),
|
||||
arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()),
|
||||
)
|
||||
return if (authority == null) base else base + arrayOf(VacTag.assemble(authority))
|
||||
}
|
||||
|
||||
/** Strict decimal (`0|[1-9][0-9]*`): digits only, no sign, exponent, radix prefix, padding or leading zero. */
|
||||
private fun strictDecimal(value: String?): Int? {
|
||||
if (value.isNullOrEmpty() || value.length > 9 || !value.all { it in '0'..'9' }) return null
|
||||
if (value.length > 1 && value[0] == '0') return null
|
||||
return value.toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* The `["chunk", i, n]` position of a kind-3303 rumor as a 1-based `(i, n)` pair, or null when
|
||||
* the tag is malformed: non-decimal, `i < 1`, `n < 1` or `i > n` (a 0-based chunk included).
|
||||
* An absent tag reads as the single chunk `(1, 1)`, as the reference client does.
|
||||
*/
|
||||
fun chunkOf(tags: Array<Array<String>>): Pair<Int, Int>? {
|
||||
val tag = tags.firstOrNull { it.isNotEmpty() && it[0] == TAG_CHUNK } ?: return 1 to 1
|
||||
val index = strictDecimal(tag.getOrNull(1)) ?: return null
|
||||
val count = strictDecimal(tag.getOrNull(2)) ?: return null
|
||||
if (index < 1 || count < 1 || index > count) return null
|
||||
return index to count
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits [blobs] into chunks that each fit one kind-3303 rumor: at most
|
||||
* [MAX_BLOBS_PER_CHUNK] blobs AND a rumor JSON of at most [REKEY_RUMOR_MAX_BYTES], given the
|
||||
* [envelopeBytes] the rumor costs with an empty content (measure it at the widest `chunk` tag
|
||||
* the rotation can carry — over-reserving only makes chunks smaller). Mirrors the reference
|
||||
* client's budget byte for byte: the content's own `[]`, one comma between blobs, and each
|
||||
* blob at its JSON-escaped length inside the content string. A lone over-budget blob is kept
|
||||
* (blobs are indivisible). Always yields at least one (possibly empty) chunk.
|
||||
*/
|
||||
fun chunkBlobs(
|
||||
blobs: List<RekeyBlob>,
|
||||
envelopeBytes: Int,
|
||||
): List<List<RekeyBlob>> {
|
||||
val budget = REKEY_RUMOR_MAX_BYTES - envelopeBytes
|
||||
val chunks = ArrayList<List<RekeyBlob>>()
|
||||
var current = ArrayList<RekeyBlob>()
|
||||
var used = 2 // the content's own "[]"
|
||||
for (blob in blobs) {
|
||||
val cost = escapedJsonLength(blob)
|
||||
if (current.isNotEmpty() && (current.size >= MAX_BLOBS_PER_CHUNK || used + 1 + cost > budget)) {
|
||||
chunks.add(current)
|
||||
current = ArrayList()
|
||||
used = 2
|
||||
}
|
||||
used += cost + (if (current.isNotEmpty()) 1 else 0)
|
||||
current.add(blob)
|
||||
}
|
||||
if (current.isNotEmpty() || chunks.isEmpty()) chunks.add(current)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** A blob's byte length inside the rumor's JSON-escaped `content` string (without outer quotes). */
|
||||
private fun escapedJsonLength(blob: RekeyBlob): Int {
|
||||
val raw = encodeContent(listOf(blob)).let { it.substring(1, it.length - 1) }
|
||||
var extra = 0
|
||||
for (c in raw) if (c == '"' || c == '\\') extra++
|
||||
return raw.encodeToByteArray().size + extra
|
||||
}
|
||||
|
||||
/** Serializes a chunk's blobs into the kind-3303 rumor content. */
|
||||
fun encodeContent(blobs: List<RekeyBlob>): String = ConcordJson.instance.encodeToString(ListSerializer(RekeyBlob.serializer()), blobs)
|
||||
@@ -118,6 +190,16 @@ object ConcordRekey {
|
||||
/** CORD-06 §1: a single kind-3303 event carries at most this many per-recipient blobs. */
|
||||
const val MAX_BLOBS_PER_CHUNK = 120
|
||||
|
||||
/**
|
||||
* Byte ceiling on one kind-3303 rumor's JSON, beside the 120-blob count cap. Base blobs are
|
||||
* wider than channel ones, and 120 of them pushed the wrap's NIP-44 plaintext (the seal, which
|
||||
* carries the rumor's own NIP-44 ciphertext as base64) past the 65,535-byte cap. 40,960 is the
|
||||
* top of the NIP-44 padding bucket that still wraps — the reference client's
|
||||
* `REKEY_RUMOR_MAX_BYTES`. Capacity: 120 blobs at 72 bytes (the count cap binds), 99 at 104,
|
||||
* 90 at 136. Finer chunking is always wire-legal.
|
||||
*/
|
||||
const val REKEY_RUMOR_MAX_BYTES = 40_960
|
||||
|
||||
/**
|
||||
* Builds a rekey blob for one recipient using [rotatorSigner] instead of a raw
|
||||
* private key, so a NIP-46 bunker rotator can mint blobs without exposing its
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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.cord06Rekey
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
|
||||
/**
|
||||
* The `vac` authority citation on a rotation (CORD-06 §3 "Authority", CORD-04 §5): a rotation
|
||||
* cites the Grant its rotator acts under like any authority action, so a just-demoted admin's
|
||||
* rotation is never honored by a lagging client. The owner cites nothing — the `community_id`
|
||||
* proves them.
|
||||
*
|
||||
* The citation is a *sync floor*, not the verdict: a verifier refuses the rotation until it
|
||||
* holds at least the cited Grant edition, then resolves the rotator's rank against its current
|
||||
* roster (the caller's `hasPermission(BAN)` check). Mirrors the reference client's
|
||||
* `citationSatisfied`: a newer Grant head than the cited one satisfies, the same version must
|
||||
* match the edition hash (a fork is refused), an older head parks the rotation (fail closed).
|
||||
*
|
||||
* [heads] is the authority-gated head of every Control entity keyed by `eid` hex — exactly
|
||||
* [ConcordCommunityState.authorizedHeads] over the current epoch's editions ([headsOf]).
|
||||
*/
|
||||
object ConcordRotationAuthority {
|
||||
/** The authority-gated entity heads a citation is minted from and checked against. */
|
||||
fun headsOf(
|
||||
editions: Collection<ControlEdition>,
|
||||
communityIdHex: HexKey,
|
||||
ownerPubKey: HexKey,
|
||||
): Map<String, EntityFloor> = ConcordCommunityState.authorizedHeads(editions, communityIdHex.hexToByteArray(), ownerPubKey)
|
||||
|
||||
/**
|
||||
* The citation [actor] puts on a rotation: their own Grant's current head, or null for the
|
||||
* owner (who cites nothing) and for an actor with no Grant (whose rotation nobody honors).
|
||||
*/
|
||||
fun citationFor(
|
||||
communityIdHex: HexKey,
|
||||
actor: HexKey,
|
||||
ownerPubKey: HexKey,
|
||||
heads: Map<String, EntityFloor>,
|
||||
): AuthorityCitation? {
|
||||
if (actor.equals(ownerPubKey, ignoreCase = true)) return null
|
||||
val eid = ConcordKeyDerivation.grantCoordinate(communityIdHex.hexToByteArray(), actor.lowercase().hexToByteArray())
|
||||
val head = heads[eid.toHexKey()] ?: return null
|
||||
val hash = head.hashHex.hexToByteArrayOrNull() ?: return null
|
||||
return AuthorityCitation(eid, head.version, hash)
|
||||
}
|
||||
|
||||
/** Whether [citation] authorizes [actor]'s rotation under the verifier's [heads]. */
|
||||
fun citationSatisfied(
|
||||
communityIdHex: HexKey,
|
||||
actor: HexKey,
|
||||
ownerPubKey: HexKey,
|
||||
citation: AuthorityCitation?,
|
||||
heads: Map<String, EntityFloor>,
|
||||
): Boolean {
|
||||
if (actor.equals(ownerPubKey, ignoreCase = true)) return true
|
||||
if (citation == null) return false
|
||||
// Must name the actor's OWN Grant coordinate.
|
||||
val eid = ConcordKeyDerivation.grantCoordinate(communityIdHex.hexToByteArray(), actor.lowercase().hexToByteArray()).toHexKey()
|
||||
if (citation.grantId.toHexKey() != eid) return false
|
||||
val head = heads[eid] ?: return false
|
||||
return when {
|
||||
head.version > citation.grantVersion -> true
|
||||
// At exactly it: the hash must match our fold's winner, not a fork.
|
||||
head.version == citation.grantVersion -> head.hashHex.equals(citation.grantHash.toHexKey(), ignoreCase = true)
|
||||
// Behind it: park until the Grant arrives.
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -226,6 +226,25 @@ object ConcordKeyDerivation {
|
||||
memberXOnly: ByteArray,
|
||||
): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.GRANT, memberXOnly))
|
||||
|
||||
/**
|
||||
* The dissolution tombstone address for a community (CORD-02 §9, A.6):
|
||||
* `group_key("concord/dissolved", community_id, 0…0)`, no epoch.
|
||||
*
|
||||
* Derived from the public `community_id` alone, so every member past or present finds
|
||||
* the same grave whatever epoch they hold — and so can anyone else, which is why a
|
||||
* tombstone read here is honored only when owner-signed and bound to this id.
|
||||
*/
|
||||
fun dissolvedPlaneKey(communityId: ByteArray): GroupKey = groupKey(ConcordLabels.DISSOLVED, communityId, ByteArray(32))
|
||||
|
||||
/**
|
||||
* A Channel's Pin List entity id (CORD-04 §7, A.6): `hkdf32(communityId, "concord/pins" ‖ 0x00 ‖
|
||||
* channel_id)`. Derived from the community id, so a list binds to its Community by construction.
|
||||
*/
|
||||
fun pinsCoordinate(
|
||||
communityId: ByteArray,
|
||||
channelId: ByteArray,
|
||||
): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.PINS, channelId))
|
||||
|
||||
/** The community-wide Banlist entity id: `hkdf32(communityId, "concord/banlist" ‖ 0x00 ‖ ZERO32)`. */
|
||||
fun banlistCoordinate(communityId: ByteArray): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.BANLIST, ByteArray(32)))
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ object ConcordLabels {
|
||||
/** Banlist coordinate derivation (CORD-04). */
|
||||
const val BANLIST = "concord/banlist"
|
||||
|
||||
/** A Channel's Pin List coordinate (CORD-04 §7). */
|
||||
const val PINS = "concord/pins"
|
||||
|
||||
/** Invite-link coordinate derivation (CORD-05). */
|
||||
const val INVITE_LINKS = "concord/invite-links"
|
||||
|
||||
|
||||
+46
-4
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.verifyId
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
@@ -79,7 +80,7 @@ object ConcordStreamEnvelope {
|
||||
): Event {
|
||||
val content =
|
||||
if (encrypted) {
|
||||
Nip44.v2.encrypt(rumor.toJson(), stream.conversationKey).encodePayload()
|
||||
encryptChecked(rumor.toJson(), stream.conversationKey)
|
||||
} else {
|
||||
rumor.toJson()
|
||||
}
|
||||
@@ -115,7 +116,7 @@ object ConcordStreamEnvelope {
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): Event {
|
||||
val streamSigner = NostrSignerSync(KeyPair(privKey = signerKey.secretKey))
|
||||
val content = Nip44.v2.encrypt(seal.toJson(), readConversationKey).encodePayload()
|
||||
val content = encryptChecked(seal.toJson(), readConversationKey)
|
||||
val ephemeralP = KeyPair().pubKey.toHexKey()
|
||||
val kind = if (ephemeral) KIND_WRAP_EPHEMERAL else KIND_WRAP
|
||||
return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)), content)
|
||||
@@ -200,7 +201,7 @@ object ConcordStreamEnvelope {
|
||||
}
|
||||
require(wrap.verify()) { "Wrap signature/id is invalid" }
|
||||
|
||||
val seal = Event.fromJson(Nip44.v2.decrypt(wrap.content, readConversationKey))
|
||||
val seal = Event.fromJson(decryptChecked(wrap.content, readConversationKey))
|
||||
require(seal.kind == KIND_SEAL_ENCRYPTED || seal.kind == KIND_SEAL_PLAINTEXT) {
|
||||
"Not a Concord seal: kind ${seal.kind}"
|
||||
}
|
||||
@@ -208,7 +209,7 @@ object ConcordStreamEnvelope {
|
||||
|
||||
val rumorJson =
|
||||
if (seal.kind == KIND_SEAL_ENCRYPTED) {
|
||||
Nip44.v2.decrypt(seal.content, readConversationKey)
|
||||
decryptChecked(seal.content, readConversationKey)
|
||||
} else {
|
||||
seal.content
|
||||
}
|
||||
@@ -257,6 +258,47 @@ object ConcordStreamEnvelope {
|
||||
): OpenedStreamEvent? = openOrNull(wrap, keys.address, keys.readKey.conversationKey)
|
||||
|
||||
private val EMPTY_TAGS = emptyArray<Array<String>>()
|
||||
|
||||
/**
|
||||
* NIP-44's hard plaintext cap (CORD-02 Appendix B). Every layer of a Concord event is a NIP-44
|
||||
* plaintext, and the spec makes enforcing the cap each implementation's job: quartz's NIP-44
|
||||
* silently switches to its extended (u32-prefixed) format past it, which strict readers —
|
||||
* the reference client among them — cannot decrypt.
|
||||
*/
|
||||
const val NIP44_MAX_PLAINTEXT = 65_535
|
||||
|
||||
/** The largest standard-format NIP-44 v2 ciphertext: the u16 prefix plus the 64 KiB pad bucket. */
|
||||
private const val MAX_STANDARD_CIPHERTEXT = 2 + 65_536
|
||||
|
||||
/** base64 of version (1) + nonce (32) + [MAX_STANDARD_CIPHERTEXT] + mac (32): anything longer is not standard NIP-44. */
|
||||
private const val MAX_STANDARD_PAYLOAD = 87_472
|
||||
|
||||
/**
|
||||
* NIP-44 v2 encrypt that refuses a plaintext over [NIP44_MAX_PLAINTEXT] UTF-8 bytes instead of
|
||||
* minting an extended-format payload (the reference client's `encryptChecked`).
|
||||
*/
|
||||
private fun encryptChecked(
|
||||
plaintext: String,
|
||||
conversationKey: ByteArray,
|
||||
): String {
|
||||
val size = plaintext.encodeToByteArray().size
|
||||
require(size <= NIP44_MAX_PLAINTEXT) { "Concord plaintext is $size bytes, over the NIP-44 cap of $NIP44_MAX_PLAINTEXT (CORD-02 Appendix B)" }
|
||||
return Nip44.v2.encrypt(plaintext, conversationKey).encodePayload()
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-44 v2 decrypt that only accepts the standard format: a payload or ciphertext too large
|
||||
* for the u16 length prefix is the extended format, which no strict Concord client can read
|
||||
* and none of ours ever writes, so it is refused before any decryption work.
|
||||
*/
|
||||
private fun decryptChecked(
|
||||
payload: String,
|
||||
conversationKey: ByteArray,
|
||||
): String {
|
||||
val info = Nip44v2.EncryptedInfo.decodePayload(payload, MAX_STANDARD_PAYLOAD)
|
||||
require(info.ciphertext.size <= MAX_STANDARD_CIPHERTEXT) { "Extended-format NIP-44 payload refused (CORD-02 Appendix B)" }
|
||||
return Nip44.v2.decrypt(info, conversationKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -99,6 +99,7 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
|
||||
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
|
||||
@@ -873,6 +874,7 @@ class EventFactory {
|
||||
RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ConcordCommunityListFragmentEvent.KIND -> ConcordCommunityListFragmentEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ConcordInviteListEvent.KIND -> ConcordInviteListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ConcordInviteBundleEvent.KIND -> ConcordInviteBundleEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.cord02Community
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* CORD-03 §2 channel fold rules: deletion is terminal across the whole accepted chain, and a
|
||||
* Channel name is 1..64 UTF-8 bytes — an edition breaking the name rule is unauthorized and the
|
||||
* fold falls back to the previous candidate (the reference client's channel gate).
|
||||
*/
|
||||
class ChannelFoldConformanceTest {
|
||||
private val owner = "0f".repeat(32)
|
||||
private val chan = "c1".repeat(32)
|
||||
|
||||
private fun chained(
|
||||
version: Long,
|
||||
prev: ControlEdition?,
|
||||
content: String,
|
||||
author: String = owner,
|
||||
) = ControlEdition(ControlEntityKind.CHANNEL, chan.hexToByteArray(), version, prev?.hash, null, content, author, "r-$version", version)
|
||||
|
||||
@Test
|
||||
fun aDeletedChannelCannotBeResurrectedByALaterEdition() {
|
||||
val c0 = chained(0, null, """{"name":"general"}""")
|
||||
val c1 = chained(1, c0, """{"name":"general","deleted":true}""")
|
||||
val c2 = chained(2, c1, """{"name":"general","deleted":false}""")
|
||||
|
||||
val state = ControlFixtures.fold(listOf(c0, c1, c2), owner)
|
||||
assertNull(state.channels[chan], "a deletion anywhere in the accepted chain is terminal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnauthorizedDeleteDoesNotRetireTheChannel() {
|
||||
val troll = "77".repeat(32)
|
||||
val c0 = chained(0, null, """{"name":"general"}""")
|
||||
val forged = chained(1, c0, """{"name":"general","deleted":true}""", author = troll)
|
||||
|
||||
val state = ControlFixtures.fold(listOf(c0, forged), owner)
|
||||
assertEquals("general", state.channels[chan]?.definition?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOverCapOrEmptyNameFallsBackToThePreviousEdition() {
|
||||
val c0 = chained(0, null, """{"name":"general"}""")
|
||||
val tooLong = chained(1, c0, """{"name":"${"x".repeat(65)}"}""")
|
||||
assertEquals(
|
||||
"general",
|
||||
ControlFixtures
|
||||
.fold(listOf(c0, tooLong), owner)
|
||||
.channels[chan]
|
||||
?.definition
|
||||
?.name,
|
||||
)
|
||||
|
||||
val empty = chained(1, c0, """{"name":""}""")
|
||||
assertEquals(
|
||||
"general",
|
||||
ControlFixtures
|
||||
.fold(listOf(c0, empty), owner)
|
||||
.channels[chan]
|
||||
?.definition
|
||||
?.name,
|
||||
)
|
||||
|
||||
// Exactly 64 bytes is fine — counted in UTF-8, so 16 four-byte emoji hit the cap too.
|
||||
val atCap = chained(1, c0, """{"name":"${"x".repeat(64)}"}""")
|
||||
assertEquals(
|
||||
"x".repeat(64),
|
||||
ControlFixtures
|
||||
.fold(listOf(c0, atCap), owner)
|
||||
.channels[chan]
|
||||
?.definition
|
||||
?.name,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theNameRuleCountsUtf8Bytes() {
|
||||
val emoji = "😀" // 4 bytes in UTF-8
|
||||
assertTrue(ChannelEntity.isValidName(emoji.repeat(16)))
|
||||
assertFalse(ChannelEntity.isValidName(emoji.repeat(16) + "a"))
|
||||
assertFalse(ChannelEntity.isValidName(""))
|
||||
assertTrue(ChannelEntity(name = "general").hasValidName())
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -30,6 +30,7 @@ import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
@@ -87,7 +88,13 @@ class ConcordCommunityFactoryTest {
|
||||
val community =
|
||||
ConcordCommunityFactory.create(owner, name = "Gamers", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
|
||||
val state = ConcordCommunityState.fold(community.genesisEditions, community.ownerPubKey)
|
||||
val state = ConcordCommunityState.fold(community.genesisEditions, community.communityId, community.ownerPubKey)
|
||||
|
||||
// CORD-04 §1: versions start at 1.
|
||||
community.genesisEditions.forEach {
|
||||
assertEquals(1L, it.version)
|
||||
assertEquals(null, it.prevHash)
|
||||
}
|
||||
|
||||
assertEquals("Gamers", state.metadata?.name)
|
||||
assertEquals(listOf("wss://r.example"), state.metadata?.relays)
|
||||
@@ -103,6 +110,16 @@ class ConcordCommunityFactoryTest {
|
||||
assertFalse(state.dissolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refusesAGenesisPastTheMetadataCaps() =
|
||||
runTest {
|
||||
// CORD-02 §6: every reader drops metadata past 64 bytes of name / 10,000 of description,
|
||||
// so a genesis past them would found a community with no metadata at all.
|
||||
assertFailsWith<IllegalArgumentException> { ConcordCommunityFactory.create(owner, "n".repeat(65), 1L) }
|
||||
assertFailsWith<IllegalArgumentException> { ConcordCommunityFactory.create(owner, "ok", 1L, description = "d".repeat(10_001)) }
|
||||
assertEquals("n".repeat(64), ConcordCommunityFactory.create(owner, "n".repeat(64), 1L).let { ConcordCommunityState.fold(it.genesisEditions, it.communityId, it.ownerPubKey).metadata?.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentCommunitiesFromSameOwnerHaveDistinctIds() =
|
||||
runTest {
|
||||
|
||||
+78
-11
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord02Community
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -46,23 +47,23 @@ class ConcordCommunityStateTest {
|
||||
fun foldsMetadataChannelsRolesAndAuthority() {
|
||||
val editions =
|
||||
listOf(
|
||||
edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"My Server","description":"hi"}"""),
|
||||
edition(ControlEntityKind.METADATA, ControlFixtures.COMMUNITY_ID_HEX, """{"name":"My Server","description":"hi"}"""),
|
||||
edition(ControlEntityKind.CHANNEL, "c1".repeat(32), """{"name":"general","private":false}"""),
|
||||
edition(ControlEntityKind.CHANNEL, "c2".repeat(32), """{"name":"voice-lounge","private":false,"voice":true}"""),
|
||||
edition(ControlEntityKind.CHANNEL, "c3".repeat(32), """{"name":"old","deleted":true}"""),
|
||||
edition(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""),
|
||||
edition(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$alice","role_ids":["$adminRole"]}"""),
|
||||
edition(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), """{"member":"$alice","role_ids":["$adminRole"]}"""),
|
||||
)
|
||||
|
||||
val state = ConcordCommunityState.fold(editions, owner)
|
||||
val state = ControlFixtures.fold(editions, owner)
|
||||
|
||||
assertEquals("My Server", state.metadata?.name)
|
||||
assertEquals("hi", state.metadata?.description)
|
||||
|
||||
// deleted channel excluded; general + voice channel kept
|
||||
// deleted channel excluded; the other two kept (a legacy `voice` key is just an unknown field)
|
||||
assertEquals(2, state.channels.size)
|
||||
assertEquals("general", state.channels["c1".repeat(32)]?.definition?.name)
|
||||
assertTrue(state.channels["c2".repeat(32)]?.definition?.voice == true)
|
||||
assertEquals("voice-lounge", state.channels["c2".repeat(32)]?.definition?.name)
|
||||
assertNull(state.channels["c3".repeat(32)])
|
||||
|
||||
assertNotNull(state.roles[adminRole])
|
||||
@@ -79,7 +80,7 @@ class ConcordCommunityStateTest {
|
||||
@Test
|
||||
fun anUnauthorizedChannelOrMetadataEditionMidChainDoesNotOrphanTheEditionsAboveIt() {
|
||||
val chan = "c1".repeat(32)
|
||||
val meta = "00".repeat(32)
|
||||
val meta = ControlFixtures.COMMUNITY_ID_HEX
|
||||
val troll = "77".repeat(32) // holds no roles at all
|
||||
|
||||
fun chained(
|
||||
@@ -99,20 +100,86 @@ class ConcordCommunityStateTest {
|
||||
val m1 = chained(ControlEntityKind.METADATA, meta, 1, m0, """{"name":"HACKED"}""", troll)
|
||||
val m2 = chained(ControlEntityKind.METADATA, meta, 2, m1, """{"name":"Renamed Server"}""", owner)
|
||||
|
||||
val state = ConcordCommunityState.fold(listOf(c0, c1, c2, m0, m1, m2), owner)
|
||||
val state = ControlFixtures.fold(listOf(c0, c1, c2, m0, m1, m2), owner)
|
||||
|
||||
assertEquals("renamed", state.channels[chan]?.definition?.name, "the owner's v2 rename must apply")
|
||||
assertEquals("Renamed Server", state.metadata?.name, "the owner's v2 metadata edit must apply")
|
||||
}
|
||||
|
||||
private fun chainedMeta(
|
||||
eid: String,
|
||||
version: Long,
|
||||
prev: ControlEdition?,
|
||||
content: String,
|
||||
author: String = owner,
|
||||
rumorId: String = "m-$eid-$version-$author",
|
||||
) = ControlEdition(ControlEntityKind.METADATA, eid.hexToByteArray(), version, prev?.hash, null, content, author, rumorId, version)
|
||||
|
||||
@Test
|
||||
fun dissolutionTombstoneMarksCommunityDissolved() {
|
||||
fun metadataAtAnyCoordinateButTheCommunityIdIsIgnored() {
|
||||
// CORD-04 §1: the metadata entity's eid IS the community_id. A fresh chain minted at any
|
||||
// other coordinate — even owner-signed, even at a far higher version — is not this
|
||||
// community's metadata, so it can neither shadow the real chain nor bypass it (S8).
|
||||
val real = chainedMeta(ControlFixtures.COMMUNITY_ID_HEX, 1, null, """{"name":"Real"}""")
|
||||
val decoy = chainedMeta("99".repeat(32), 50, null, """{"name":"Decoy"}""")
|
||||
assertEquals("Real", ControlFixtures.fold(listOf(real, decoy), owner).metadata?.name)
|
||||
assertNull(ControlFixtures.fold(listOf(decoy), owner).metadata, "a decoy alone is no metadata at all")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metadataPastTheNameOrDescriptionCapFallsBackToThePreviousEdition() {
|
||||
// CORD-02 §6 caps are fold gates, as in the reference client: an edition past them is
|
||||
// dropped and the entity keeps the edition below it.
|
||||
val cid = ControlFixtures.COMMUNITY_ID_HEX
|
||||
val v1 = chainedMeta(cid, 1, null, """{"name":"Fine"}""")
|
||||
val longName = chainedMeta(cid, 2, v1, """{"name":"${"n".repeat(65)}"}""")
|
||||
assertEquals("Fine", ControlFixtures.fold(listOf(v1, longName), owner).metadata?.name)
|
||||
|
||||
// 64 bytes of UTF-8 is the cap, not 64 characters: 33 two-byte characters is 66 bytes.
|
||||
val wideName = chainedMeta(cid, 2, v1, """{"name":"${"é".repeat(33)}"}""")
|
||||
assertEquals("Fine", ControlFixtures.fold(listOf(v1, wideName), owner).metadata?.name)
|
||||
|
||||
val longDescription = chainedMeta(cid, 2, v1, """{"name":"Fine2","description":"${"d".repeat(10_001)}"}""")
|
||||
assertEquals("Fine", ControlFixtures.fold(listOf(v1, longDescription), owner).metadata?.name)
|
||||
|
||||
val atTheCaps = chainedMeta(cid, 2, v1, """{"name":"${"n".repeat(64)}","description":"${"d".repeat(10_000)}"}""")
|
||||
assertEquals("n".repeat(64), ControlFixtures.fold(listOf(v1, atTheCaps), owner).metadata?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEqualVersionMetadataForkGoesToTheHigherAuthorityNotTheLowerRumorId() {
|
||||
// CORD-04 §1: "authority first, then the lower rumor id". Alice holds MANAGE_METADATA and
|
||||
// grinds a rumor id below the owner's at the same version; the owner still wins.
|
||||
val cid = ControlFixtures.COMMUNITY_ID_HEX
|
||||
val v1 = chainedMeta(cid, 1, null, """{"name":"Genesis"}""")
|
||||
val ownerV2 = chainedMeta(cid, 2, v1, """{"name":"Owner's"}""", rumorId = "ffff")
|
||||
val aliceV2 = chainedMeta(cid, 2, v1, """{"name":"Alice's"}""", author = alice, rumorId = "0000")
|
||||
val editions =
|
||||
listOf(
|
||||
edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"Doomed"}"""),
|
||||
edition(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"4"}"""), // MANAGE_METADATA
|
||||
edition(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), """{"member":"$alice","role_ids":["$adminRole"]}"""),
|
||||
v1,
|
||||
aliceV2,
|
||||
ownerV2,
|
||||
)
|
||||
assertEquals("Owner's", ControlFixtures.fold(editions, owner).metadata?.name)
|
||||
|
||||
// And an edition chained onto the owner's sibling extends the chain from there.
|
||||
val v3 = chainedMeta(cid, 3, ownerV2, """{"name":"Next"}""", author = alice)
|
||||
assertEquals("Next", ControlFixtures.fold(editions + v3, owner).metadata?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aControlPlaneVsk10EditionDoesNotDissolve() {
|
||||
// CORD-02 §9: the tombstone lives at `dissolved_pk` and must name its community. A vsk-10
|
||||
// edition on the Control Plane skips that binding, so it must never seal the community.
|
||||
val editions =
|
||||
listOf(
|
||||
edition(ControlEntityKind.METADATA, ControlFixtures.COMMUNITY_ID_HEX, """{"name":"Doomed"}"""),
|
||||
edition(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""),
|
||||
)
|
||||
val state = ConcordCommunityState.fold(editions, owner)
|
||||
assertTrue(state.dissolved)
|
||||
val state = ControlFixtures.fold(editions, owner)
|
||||
assertFalse(state.dissolved)
|
||||
assertTrue(state.withDissolved(true).dissolved)
|
||||
}
|
||||
}
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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.cord02Community
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConcordDissolutionTest {
|
||||
private val owner = NostrSignerInternal(KeyPair())
|
||||
private val stranger = NostrSignerInternal(KeyPair())
|
||||
private val communityX = ConcordKeyDerivation.communityId(owner.pubKey.hexToByteArray(), ByteArray(32) { 1 }).toHexKey()
|
||||
private val communityY = ConcordKeyDerivation.communityId(owner.pubKey.hexToByteArray(), ByteArray(32) { 2 }).toHexKey()
|
||||
|
||||
@Test
|
||||
fun tombstoneWireShapeIsChainlessAndBound() {
|
||||
val rumor = ConcordDissolution.rumor(owner.pubKey, communityX, createdAt = 1725000000)
|
||||
assertEquals(ControlEditionEvent.KIND, rumor.kind)
|
||||
assertEquals("", rumor.content)
|
||||
assertEquals(listOf(listOf("vsk", "10"), listOf("eid", communityX)), rumor.tags.map { it.toList() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addressDerivesFromTheCommunityIdAlone() {
|
||||
// A.6: `concord/dissolved`, ikm = community_id, id = 0…0, no epoch.
|
||||
val expected = ConcordKeyDerivation.groupKey("concord/dissolved", communityX.hexToByteArray(), ByteArray(32))
|
||||
assertEquals(expected.publicKeyHex, ConcordDissolution.planeKey(communityX).publicKeyHex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ownerTombstoneDissolvesItsOwnCommunity() =
|
||||
runTest {
|
||||
val wrap = ConcordDissolution.build(owner, communityX)
|
||||
assertEquals(ConcordDissolution.planeKey(communityX).publicKeyHex, wrap.pubKey)
|
||||
assertTrue(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonOwnerTombstoneIsNoise() =
|
||||
runTest {
|
||||
val wrap = ConcordDissolution.build(stranger, communityX)
|
||||
assertFalse(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTombstoneForXReWrappedAtYDoesNotKillY() =
|
||||
runTest {
|
||||
// The replay CORD-02 §9 closes: lift X's genuine owner seal and re-wrap it verbatim at Y's
|
||||
// (public) dissolved address. The seal still verifies, but its eid names X.
|
||||
val planeX = ConcordDissolution.planeKey(communityX)
|
||||
val sealForX = ConcordStreamEnvelope.seal(ConcordDissolution.rumor(owner.pubKey, communityX), planeX, owner, encrypted = false)
|
||||
val reWrappedAtY = ConcordStreamEnvelope.wrapSeal(sealForX, ConcordDissolution.planeKey(communityY))
|
||||
assertFalse(ConcordDissolution.isDissolved(listOf(reWrappedAtY), communityY, owner.pubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLegacyAllZeroEidIsRefused() =
|
||||
runTest {
|
||||
val plane = ConcordDissolution.planeKey(communityX)
|
||||
val legacy =
|
||||
RumorAssembler.assembleRumor(
|
||||
owner.pubKey,
|
||||
eventTemplate<ControlEditionEvent>(ControlEditionEvent.KIND, "") {
|
||||
add(arrayOf("vsk", ControlEntityKind.DISSOLVED.wire))
|
||||
add(arrayOf("eid", "00".repeat(32)))
|
||||
},
|
||||
)
|
||||
val wrap = ConcordStreamEnvelope.wrap(legacy, plane, owner, encrypted = false)
|
||||
assertFalse(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEncryptedSealIsRefused() =
|
||||
runTest {
|
||||
val plane = ConcordDissolution.planeKey(communityX)
|
||||
val wrap = ConcordStreamEnvelope.wrap(ConcordDissolution.rumor(owner.pubKey, communityX), plane, owner, encrypted = true)
|
||||
assertFalse(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey))
|
||||
}
|
||||
}
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
/*
|
||||
* 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.cord02Community
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* CORD-02 §8 wire conformance, pinned to bytes produced by the reference client's own serializer
|
||||
* (Armada `src/concord/lib/listFrag.ts` @ 7588884, run under Node against the same fixtures).
|
||||
* Byte identity is the contract: identical state must fragment and serialize identically across
|
||||
* implementations, or the canonical-bytes tie-break flaps between two clients' republishes.
|
||||
*/
|
||||
class ConcordListFragmentsTest {
|
||||
private val json = Json
|
||||
|
||||
private fun parse(s: String) = json.parseToJsonElement(s).jsonObject
|
||||
|
||||
/** Every §8 rule once: seed omitted after a rename, kept across a refounding with current's cosmetics, a tombstoned entry dropped, a re-join kept, unknown keys sorted and untouched. */
|
||||
private val smallInput =
|
||||
"""{"vendor/flag":{"z":1,"a":[2,{"y":1,"b":2}]},"entries":[{"community_id":"1a960f3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","seed":{"co""" +
|
||||
"""mmunity_id":"1a960f3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","owner":"f2047532a0e8f1ca30e2391636330b1f05768679a7d9e78191d66ef6919662""" +
|
||||
"""3e","owner_salt":"ba76992a6079074311285334177ddda598e8d8f9bae4df70875886d5229c26ac","community_root":"82e42e2dc2fc1a7957f3b625f5ec2cfbafe7c2546d6c92db""" +
|
||||
"""dc239953c0333bec","root_epoch":0,"control_pk":"840aa4c586fff4d34e8ffb6bcf73acf4d5e033273934bfb980e899f51aa2fd4a","channels":[],"relays":["wss://relay.""" +
|
||||
"""example.com","wss://r0.example"],"name":"Gone"},"current":{"community_id":"1a960f3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","owner":"""" +
|
||||
"""f2047532a0e8f1ca30e2391636330b1f05768679a7d9e78191d66ef69196623e","owner_salt":"ba76992a6079074311285334177ddda598e8d8f9bae4df70875886d5229c26ac","com""" +
|
||||
"""munity_root":"82e42e2dc2fc1a7957f3b625f5ec2cfbafe7c2546d6c92dbdc239953c0333bec","root_epoch":0,"control_pk":"840aa4c586fff4d34e8ffb6bcf73acf4d5e033273""" +
|
||||
"""934bfb980e899f51aa2fd4a","channels":[],"relays":["wss://relay.example.com","wss://r0.example"],"name":"Gone"},"added_at":1719800000002},{"community_id""" +
|
||||
"""":"5ec89d515d1ab8e34fcadabc030883587429fbb00092d573b0d7f8c3679dc705","seed":{"community_id":"5ec89d515d1ab8e34fcadabc030883587429fbb00092d573b0d7f8c36""" +
|
||||
"""79dc705","owner":"00155506969316836e9c4649c39eb76e244c720731a5ea708709ff3bcb3212a8","owner_salt":"51e0ed5c000f3205a88bca9498b39922f08def28b2da77f063cf""" +
|
||||
"""88b9708607a0","community_root":"aea3ef4992ed46d0190f47392d69a647b28e1b57260698c3462afa76f53fd428","root_epoch":0,"control_pk":"ee8bb460e7251e4194e7b5c""" +
|
||||
"""8a56a7eaeb9e3f7e7aabc834bf2f53a081868fd28","channels":[],"relays":["wss://relay.example.com","wss://r1.example"],"name":"Back"},"current":{"community_""" +
|
||||
"""id":"5ec89d515d1ab8e34fcadabc030883587429fbb00092d573b0d7f8c3679dc705","owner":"00155506969316836e9c4649c39eb76e244c720731a5ea708709ff3bcb3212a8","own""" +
|
||||
"""er_salt":"51e0ed5c000f3205a88bca9498b39922f08def28b2da77f063cf88b9708607a0","community_root":"aea3ef4992ed46d0190f47392d69a647b28e1b57260698c3462afa76""" +
|
||||
"""f53fd428","root_epoch":0,"control_pk":"ee8bb460e7251e4194e7b5c8a56a7eaeb9e3f7e7aabc834bf2f53a081868fd28","channels":[],"relays":["wss://relay.example.""" +
|
||||
"""com","wss://r1.example"],"name":"Back"},"added_at":1722500000000},{"community_id":"5ef38335d3bbd0a5d0241fdcdd02d600170507cf47b2249d7de9e74b119623f4","""" +
|
||||
"""seed":{"community_id":"5ef38335d3bbd0a5d0241fdcdd02d600170507cf47b2249d7de9e74b119623f4","owner":"713a34a3c313be426d15b5c62ca9c115a1fa6a3a1b51a8f4c6e3""" +
|
||||
"""74b001a45310","owner_salt":"dbc4579ae2b3ab293213f42bb852706ea995c3b5c3987f8aa9faae5004acb3cf","community_root":"6f432a684ce828f64eee716b22121f3c6eccda""" +
|
||||
"""6752dd1c2e5413c9272d12d714","root_epoch":1,"control_pk":"bb30490c32fa152d1d7ed3135f7106626a0cf6b8c78d0664f762a25287a04f8c","channels":[{"id":"e83a3324""" +
|
||||
"""ddbbbcecac7111372041ea2744331b22301a97b834c8db26f9493c71","key":"f9ee91030abe276e839e1e790bbcc68777610df7f4200f75dd5d3a01a4ab2f89","epoch":1,"name":"s""" +
|
||||
"""taff-Stale"}],"relays":["wss://relay.example.com","wss://r2.example"],"name":"Stale"},"current":{"community_id":"5ef38335d3bbd0a5d0241fdcdd02d60017050""" +
|
||||
"""7cf47b2249d7de9e74b119623f4","owner":"713a34a3c313be426d15b5c62ca9c115a1fa6a3a1b51a8f4c6e374b001a45310","owner_salt":"dbc4579ae2b3ab293213f42bb852706e""" +
|
||||
"""a995c3b5c3987f8aa9faae5004acb3cf","community_root":"4e62eea03e8bc82ee7871fc927b8a0768e39116a2b9ad8cc9ab0c1e2c40c15a4","root_epoch":3,"control_pk":"b22""" +
|
||||
"""cb88bb1cfbb8c4f8697fb982e79c22065e4c5e8afc9336b8da44c550f03a6","channels":[{"id":"e83a3324ddbbbcecac7111372041ea2744331b22301a97b834c8db26f9493c71","k""" +
|
||||
"""ey":"e21fda697ca9afaf39201db8e25ae77f4bd32c69b4a7db8e5214a349f3e404df","epoch":3,"name":"staff-Fresh"}],"relays":["wss://relay.example.com","wss://r2.""" +
|
||||
"""example"],"name":"Fresh","control_root":"170ef9cce8cce1cacb0fa729f43db38756632e3b5e134408f85bb7d3163e41fd"},"added_at":1719800000001,"held_roots":[{"e""" +
|
||||
"""poch":1,"key":"82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828"}]},{"community_id":"a8e2754881acda66e47dc5a810d0f16a384742ff5b0022825""" +
|
||||
"""b5cd915382bdf65","seed":{"community_id":"a8e2754881acda66e47dc5a810d0f16a384742ff5b0022825b5cd915382bdf65","owner":"1557f949eb074ee1de813d3598b394ea65""" +
|
||||
"""4e9066ba7dd5ffe290848c31a8c9c8","owner_salt":"dc90cf07de907ccc64636ceddb38e552a1a0d984743b1f36a447b73877012c39","community_root":"e6e642c51a2df47d476e""" +
|
||||
"""8961b0a9a9db2bef4116761960ac74c19cb7c46fb397","root_epoch":0,"control_pk":"e412e33f694277b150dbdd801f378cc1886769de7a398f2b06e3713616c5133e","channels""" +
|
||||
"""":[{"id":"797e5887b3e390bc65acb5a81c47d1fe418c9bc160a4adb71fb31fa18981bc18","key":"b76205818bb9254a3af1e9900cdb486617b60265c4e21e600d6bbcd979ac3389","""" +
|
||||
"""epoch":0,"name":"staff-Old Name"}],"relays":["wss://relay.example.com","wss://r1.example"],"name":"Old Name"},"current":{"community_id":"a8e2754881acd""" +
|
||||
"""a66e47dc5a810d0f16a384742ff5b0022825b5cd915382bdf65","owner":"1557f949eb074ee1de813d3598b394ea654e9066ba7dd5ffe290848c31a8c9c8","owner_salt":"dc90cf07""" +
|
||||
"""de907ccc64636ceddb38e552a1a0d984743b1f36a447b73877012c39","community_root":"e6e642c51a2df47d476e8961b0a9a9db2bef4116761960ac74c19cb7c46fb397","root_ep""" +
|
||||
"""och":0,"control_pk":"e412e33f694277b150dbdd801f378cc1886769de7a398f2b06e3713616c5133e","channels":[{"id":"797e5887b3e390bc65acb5a81c47d1fe418c9bc160a4""" +
|
||||
"""adb71fb31fa18981bc18","key":"b76205818bb9254a3af1e9900cdb486617b60265c4e21e600d6bbcd979ac3389","epoch":0,"name":"staff-New Name"}],"relays":["wss://re""" +
|
||||
"""lay.example.com","wss://r1.example"],"name":"New Name"},"added_at":1719800000000,"invite_ref":"naddr1xyz#frag"}],"tombstones":[{"community_id":"1a960f""" +
|
||||
"""3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","removed_at":1722400000000,"vendor/why":"left"},{"community_id":"5ec89d515d1ab8e34fcadabc0""" +
|
||||
"""30883587429fbb00092d573b0d7f8c3679dc705","removed_at":1722400000000}]}"""
|
||||
|
||||
private val smallExpected =
|
||||
"""{"frags":1,"entries":[{"community_id":"XsidUV0auONPytq8AwiDWHQp-7AAktVzsNf4w2edxwU","current":{"owner":"ABVVBpaTFoNunEZJw563biRMcgcxpepwhwn_O8syEqg","""" +
|
||||
"""owner_salt":"UeDtXAAPMgWoi8qUmLOZIvCN7yiy2nfwY8-IuXCGB6A","community_root":"rqPvSZLtRtAZD0c5LWmmR7KOG1cmBpjDRir6dvU_1Cg","root_epoch":0,"control_pk":"""" +
|
||||
"""7ou0YOclHkGU57XIpWp-rrnj9-eqvINL8vU6CBho_Sg","relays":["wss://relay.example.com","wss://r1.example"],"name":"Back"},"added_at":1722500000000},{"commun""" +
|
||||
"""ity_id":"XvODNdO70KXQJB_c3QLWABcFB89HsiSdfennSxGWI_Q","seed":{"owner":"cTo0o8MTvkJtFbXGLKnBFaH6ajobUaj0xuN0sAGkUxA","owner_salt":"28RXmuKzqykyE_QruFJw""" +
|
||||
"""bqmVw7XDmH-KqfquUASss88","community_root":"b0MqaEzoKPZO7nFrIhIfPG7M2mdS3RwuVBPJJy0S1xQ","root_epoch":1,"control_pk":"uzBJDDL6FS0dftMTX3EGYmoM9rjHjQZk9""" +
|
||||
"""2KiUoegT4w","channels":[{"id":"6DozJN27vOyscRE3IEHqJ0QzGyIwGpe4NMjbJvlJPHE","key":"-e6RAwq-J26Dnh55C7zGh3dhDff0IA913V06AaSrL4k","epoch":1,"name":"staf""" +
|
||||
"""f-Fresh"}],"relays":["wss://relay.example.com","wss://r2.example"],"name":"Fresh"},"current":{"owner":"cTo0o8MTvkJtFbXGLKnBFaH6ajobUaj0xuN0sAGkUxA","o""" +
|
||||
"""wner_salt":"28RXmuKzqykyE_QruFJwbqmVw7XDmH-KqfquUASss88","community_root":"TmLuoD6LyC7nhx_JJ7igdo45EWormtjMmrDB4sQMFaQ","root_epoch":3,"control_pk":"s""" +
|
||||
"""iy4i7HPu4xPhpf7mC55wiBl5MXor8kza42kTFUPA6Y","control_root":"Fw75zOjM4crLD6cp9D2zh1ZjLjteE0QI-Fu30xY-Qf0","channels":[{"id":"6DozJN27vOyscRE3IEHqJ0QzGy""" +
|
||||
"""IwGpe4NMjbJvlJPHE","key":"4h_aaXypr685IB244lrnf0vTLGm0p9uOUhSjSfPkBN8","epoch":3,"name":"staff-Fresh"}],"relays":["wss://relay.example.com","wss://r2.""" +
|
||||
"""example"],"name":"Fresh"},"added_at":1719800000001,"held_roots":[{"epoch":1,"key":"82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828"}]""" +
|
||||
"""},{"community_id":"qOJ1SIGs2mbkfcWoENDxajhHQv9bACKCW1zZFTgr32U","current":{"owner":"FVf5SesHTuHegT01mLOU6mVOkGa6fdX_4pCEjDGoycg","owner_salt":"3JDPB96""" +
|
||||
"""QfMxkY2zt2zjlUqGg2YR0Ox82pEe3OHcBLDk","community_root":"5uZCxRot9H1HbolhsKmp2yvvQRZ2GWCsdMGct8Rvs5c","root_epoch":0,"control_pk":"5BLjP2lCd7FQ292AHzeM""" +
|
||||
"""wYhnad56OY8rBuNxNhbFEz4","channels":[{"id":"eX5Yh7PjkLxlrLWoHEfR_kGMm8FgpK23H7MfoYmBvBg","key":"t2IFgYu5JUo68emQDNtIZhe2AmXE4h5gDWu82XmsM4k","epoch":0""" +
|
||||
""","name":"staff-New Name"}],"relays":["wss://relay.example.com","wss://r1.example"],"name":"New Name"},"added_at":1719800000000,"invite_ref":"naddr1xyz""" +
|
||||
"""#frag"}],"tombstones":[{"community_id":"GpYPP4TP5Vj5RIm4RMs4My_0ZokbLnelQ_PpbGJjRlc","removed_at":1722400000000,"vendor/why":"left"},{"community_id":"""" +
|
||||
"""XsidUV0auONPytq8AwiDWHQp-7AAktVzsNf4w2edxwU","removed_at":1722400000000}],"vendor/flag":{"a":[2,{"b":2,"y":1}],"z":1}}"""
|
||||
|
||||
@Test
|
||||
fun smallFixtureMatchesTheReferenceBytes() {
|
||||
assertEquals(listOf(smallExpected), ConcordListFragments.pack(parse(smallInput)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodingTheReferenceBytesAndRepackingIsStable() {
|
||||
val decoded = ConcordListFragments.decodeFragment(smallExpected)
|
||||
assertEquals(1, decoded.frags)
|
||||
assertEquals(listOf(smallExpected), ConcordListFragments.pack(decoded.doc))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeRestoresHexAndInheritsTheCommunityId() {
|
||||
val doc = ConcordListFragments.decodeFragment(smallExpected).doc
|
||||
val first = doc["entries"]!!.jsonArray[0].jsonObject
|
||||
val cid = first["community_id"]!!.jsonPrimitive.content
|
||||
assertEquals(64, cid.length)
|
||||
val current = first["current"]!!.jsonObject
|
||||
assertEquals(cid, current["community_id"]!!.jsonPrimitive.content)
|
||||
assertEquals(64, current["owner"]!!.jsonPrimitive.content.length)
|
||||
// An absent seed reads as equal to current.
|
||||
assertEquals(current, first["seed"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownFieldsKeepTheirAuthorsSpelling() {
|
||||
// held_roots is not a field §8 names, so its hex key stays hex on the wire.
|
||||
assertTrue(smallExpected.contains("\"held_roots\":[{\"epoch\":1,\"key\":\"82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828\"}]"))
|
||||
val out = ConcordListFragments.pack(ConcordListFragments.decodeFragment(smallExpected).doc).single()
|
||||
assertTrue(out.contains("82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun base64urlIsUnpaddedAndCaseSensitive() {
|
||||
val hex = "a8e2754881acda66e47dc5a810d0f16a384742ff5b0022825b5cd915382bdf65"
|
||||
val b64 = ConcordListFragments.hexToB64(hex)
|
||||
assertEquals(43, b64.length)
|
||||
assertFalse(b64.contains('='))
|
||||
assertEquals(hex, ConcordListFragments.b64ToHex(b64))
|
||||
// Not 32 bytes either way: passes through untouched.
|
||||
assertEquals("wss://relay", ConcordListFragments.hexToB64("wss://relay"))
|
||||
assertEquals("short", ConcordListFragments.b64ToHex("short"))
|
||||
}
|
||||
|
||||
private fun h(s: String) = sha256(s.encodeToByteArray()).toHexKey()
|
||||
|
||||
private fun mat(
|
||||
cid: String,
|
||||
i: Int,
|
||||
epoch: Int,
|
||||
name: String,
|
||||
withChan: Boolean,
|
||||
): JsonObject =
|
||||
buildJsonObject {
|
||||
put("community_id", cid)
|
||||
put("owner", h("owner$i"))
|
||||
put("owner_salt", h("salt$i"))
|
||||
put("community_root", h("root$i:$epoch"))
|
||||
put("root_epoch", epoch)
|
||||
put("control_pk", h("cpk$i:$epoch"))
|
||||
put(
|
||||
"channels",
|
||||
buildJsonArray {
|
||||
if (withChan) {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("id", h("chan$i"))
|
||||
put("key", h("key$i:$epoch"))
|
||||
put("epoch", epoch)
|
||||
put("name", "staff-$name")
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
put(
|
||||
"relays",
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("wss://relay.example.com"))
|
||||
add(JsonPrimitive("wss://r${i % 3}.example"))
|
||||
},
|
||||
)
|
||||
put("name", name)
|
||||
}
|
||||
|
||||
private fun bigList(): JsonObject {
|
||||
val entries =
|
||||
(0 until 300).map { i ->
|
||||
val cid = h("big$i")
|
||||
buildJsonObject {
|
||||
put("community_id", cid)
|
||||
put("seed", mat(cid, i, 0, "Community $i", i % 4 == 0))
|
||||
put("current", mat(cid, i, i % 3, "Community $i", i % 4 == 0))
|
||||
put("added_at", 1719800000000L + i)
|
||||
}
|
||||
}
|
||||
val tombs =
|
||||
(0 until 40).map { i ->
|
||||
buildJsonObject {
|
||||
put("community_id", h("gone$i"))
|
||||
put("removed_at", 1722400000000L + i)
|
||||
}
|
||||
}
|
||||
return buildJsonObject {
|
||||
put("entries", JsonArray(entries))
|
||||
put("tombstones", JsonArray(tombs))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun largeListFragmentsExactlyLikeTheReference() {
|
||||
val frags = ConcordListFragments.pack(bigList())
|
||||
assertEquals(
|
||||
listOf(
|
||||
"f25fb53425472f4249e5801192d05a4fa589d58e8fa420a2ea0489c99c0f3acc",
|
||||
"9470fb19cf3fa850b737280bd11be99e599ea9670932a5cf5fd6bb305f9738c4",
|
||||
"f73480bbce3a92ee651a04373197bf70116f6cf1db6d99686ba3596b1030f942",
|
||||
"c3f6318681a9a1fbcd8e50129b7678b0df5e7cf59ef6527d5bd6b485435d865e",
|
||||
"d66d7973f215a3dd2b44b7c6760cd21955baaa6687e02accaa05f708660e0698",
|
||||
"35d3b110eb57dd2aa116d0faee8d3858a4608d7a1d5d681775a077671eae13ac",
|
||||
),
|
||||
frags.map { sha256(it.encodeToByteArray()).toHexKey() },
|
||||
)
|
||||
for (f in frags) {
|
||||
assertTrue(ConcordListFragments.projectedEventBytes(f.encodeToByteArray().size) <= ConcordListFragments.PACK_TARGET_BYTES)
|
||||
assertEquals(6, ConcordListFragments.decodeFragment(f).frags)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- merges ----------------------------------------------------------------------------
|
||||
|
||||
private fun entry(
|
||||
cid: String,
|
||||
seed: JsonObject,
|
||||
current: JsonObject,
|
||||
addedAt: Long,
|
||||
) = buildJsonObject {
|
||||
put("community_id", cid)
|
||||
put("seed", seed)
|
||||
put("current", current)
|
||||
put("added_at", addedAt)
|
||||
}
|
||||
|
||||
private fun doc(
|
||||
entries: List<JsonObject> = emptyList(),
|
||||
tombstones: List<JsonObject> = emptyList(),
|
||||
) = JsonObject(mapOf("entries" to JsonArray(entries), "tombstones" to JsonArray(tombstones)))
|
||||
|
||||
private fun tomb(
|
||||
cid: String,
|
||||
at: Long,
|
||||
) = buildJsonObject {
|
||||
put("community_id", cid)
|
||||
put("removed_at", at)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seedMovesBackwardAndCurrentForward() {
|
||||
val cid = h("m")
|
||||
val a = doc(listOf(entry(cid, mat(cid, 1, 1, "A", false), mat(cid, 1, 2, "A", false), 10)))
|
||||
val b = doc(listOf(entry(cid, mat(cid, 1, 0, "B", false), mat(cid, 1, 3, "B", false), 20)))
|
||||
for (merged in listOf(ConcordListFragments.mergeDocs(a, b), ConcordListFragments.mergeDocs(b, a))) {
|
||||
val e = merged["entries"]!!.jsonArray.single().jsonObject
|
||||
assertEquals("0", e["seed"]!!.jsonObject["root_epoch"]!!.jsonPrimitive.content)
|
||||
assertEquals("3", e["current"]!!.jsonObject["root_epoch"]!!.jsonPrimitive.content)
|
||||
assertEquals("20", e["added_at"]!!.jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneTombstonePerCommunityTheLaterWins() {
|
||||
val cid = h("t")
|
||||
val merged = ConcordListFragments.mergeDocs(doc(tombstones = listOf(tomb(cid, 5))), doc(tombstones = listOf(tomb(cid, 9))))
|
||||
assertEquals(
|
||||
"9",
|
||||
merged["tombstones"]!!
|
||||
.jsonArray
|
||||
.single()
|
||||
.jsonObject["removed_at"]!!
|
||||
.jsonPrimitive.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStaleFragmentCannotResurrectALeave() {
|
||||
val cid = h("x")
|
||||
val joined = doc(listOf(entry(cid, mat(cid, 1, 0, "X", false), mat(cid, 1, 0, "X", false), 100)))
|
||||
val left = doc(tombstones = listOf(tomb(cid, 200)))
|
||||
val merged = ConcordListFragments.mergeDocs(left, joined)
|
||||
assertEquals(emptyList(), ConcordCommunityList.decodeDocument(merged).entries)
|
||||
// And the packed List carries the tombstone alone.
|
||||
val packed = ConcordListFragments.decodeFragment(ConcordListFragments.pack(merged).single()).doc
|
||||
assertEquals(0, packed["entries"]!!.jsonArray.size)
|
||||
assertEquals(1, packed["tombstones"]!!.jsonArray.size)
|
||||
}
|
||||
|
||||
// ---- fragment sets ---------------------------------------------------------------------
|
||||
|
||||
private fun frag(
|
||||
frags: Int,
|
||||
entries: List<JsonObject> = emptyList(),
|
||||
tombstones: List<JsonObject> = emptyList(),
|
||||
) = ConcordListFragments.serializeFragment(
|
||||
frags,
|
||||
entries.map { ConcordListFragments.entryToWire(it) },
|
||||
tombstones.mapNotNull { ConcordListFragments.tombstoneToWire(it) },
|
||||
)
|
||||
|
||||
private fun membership(i: Int): JsonObject {
|
||||
val cid = h("member$i")
|
||||
return entry(cid, mat(cid, i, 0, "M$i", false), mat(cid, i, 0, "M$i", false), 1000L + i)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theNewestFragmentDeclaresTheCountAndATieGoesLarger() {
|
||||
val set =
|
||||
ConcordListFragmentSet.of(
|
||||
listOf(
|
||||
ConcordListFragmentSet.Copy(0, 100, "a", frag(2, listOf(membership(0)))),
|
||||
ConcordListFragmentSet.Copy(1, 100, "b", frag(3, listOf(membership(1)))),
|
||||
),
|
||||
)
|
||||
assertEquals(3, set.declared)
|
||||
assertFalse(set.complete, "index 2 is unseen")
|
||||
assertEquals(2, ConcordCommunityList.decodeDocument(set.doc).entries.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnreadableHeadIsAMissingIndexNotAnOlderCopy() {
|
||||
val set =
|
||||
ConcordListFragmentSet.of(
|
||||
listOf(
|
||||
ConcordListFragmentSet.Copy(0, 100, "old", frag(1, listOf(membership(0)))),
|
||||
ConcordListFragmentSet.Copy(0, 200, "new", null),
|
||||
),
|
||||
)
|
||||
assertTrue(0 in set.unreadable)
|
||||
assertTrue(set.held.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnreadableOnlyFragmentIsNeverOverwritten() {
|
||||
// The one fragment on the wire didn't decrypt (a timed-out signer): not "no List".
|
||||
val set = ConcordListFragmentSet.of(listOf(ConcordListFragmentSet.Copy(0, 100, "a", null)))
|
||||
assertFalse(set.complete)
|
||||
assertFalse(set.isEmpty)
|
||||
assertFailsWith<ConcordListIncompleteException> { set.planWrites(doc(listOf(membership(0))), now = 1000) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnreadableNewerFragmentBlocksARepackThatWouldShrinkTheCount() {
|
||||
// Fragment 1 may declare more fragments than readable fragment 0 does; a repack to 1
|
||||
// fragment would push its memberships out of range.
|
||||
val set =
|
||||
ConcordListFragmentSet.of(
|
||||
listOf(
|
||||
ConcordListFragmentSet.Copy(0, 100, "a", frag(1, listOf(membership(0)))),
|
||||
ConcordListFragmentSet.Copy(1, 200, "b", null),
|
||||
),
|
||||
)
|
||||
assertFalse(set.complete)
|
||||
val writes = set.planWrites(ConcordListFragments.mergeDocs(set.doc, doc(listOf(membership(5)))), now = 1000)
|
||||
assertEquals(listOf(0), writes.map { it.index }, "only a scoped write into the readable fragment")
|
||||
assertEquals(1, ConcordListFragments.decodeFragment(writes.single().plaintext).frags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEntryWithUnencodableMaterialIsSkippedNotFatal() {
|
||||
val broken =
|
||||
buildJsonObject {
|
||||
put("community_id", h("broken"))
|
||||
put("current", buildJsonObject { put("name", "no keys") })
|
||||
put("added_at", 1)
|
||||
}
|
||||
val packed = ConcordListFragments.pack(doc(listOf(membership(0), broken))).single()
|
||||
assertEquals(
|
||||
1,
|
||||
ConcordListFragments
|
||||
.decodeFragment(packed)
|
||||
.doc["entries"]!!
|
||||
.jsonArray.size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fragmentsPastTheCountStayDormant() {
|
||||
val set =
|
||||
ConcordListFragmentSet.of(
|
||||
listOf(
|
||||
ConcordListFragmentSet.Copy(0, 300, "a", frag(1, listOf(membership(0)))),
|
||||
ConcordListFragmentSet.Copy(1, 100, "b", frag(2, listOf(membership(1)))),
|
||||
),
|
||||
)
|
||||
assertEquals(1, set.declared)
|
||||
assertTrue(set.complete)
|
||||
assertEquals(listOf(h("member0")), ConcordCommunityList.decodeDocument(set.doc).entries.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRepackThatShrinksEmptiesTheDroppedIndices() {
|
||||
val set =
|
||||
ConcordListFragmentSet.of(
|
||||
listOf(
|
||||
ConcordListFragmentSet.Copy(0, 100, "a", frag(2, listOf(membership(0)))),
|
||||
ConcordListFragmentSet.Copy(1, 100, "b", frag(2, listOf(membership(1)))),
|
||||
),
|
||||
)
|
||||
assertTrue(set.complete)
|
||||
val writes = set.planWrites(set.doc, now = 50)
|
||||
// Both memberships fit one fragment: 0 is rewritten with frags=1, 1 is emptied — never abandoned.
|
||||
assertEquals(listOf(0, 1), writes.map { it.index })
|
||||
assertEquals(
|
||||
2,
|
||||
ConcordListFragments
|
||||
.decodeFragment(writes[0].plaintext)
|
||||
.doc["entries"]!!
|
||||
.jsonArray.size,
|
||||
)
|
||||
assertEquals(ConcordListFragments.emptyFragment(1), writes[1].plaintext)
|
||||
// created_at always climbs past the index's previous copy, even with a clock behind it.
|
||||
assertTrue(writes.all { it.createdAt == 101L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anIncompleteListOnlyRewritesTheFragmentHoldingTheChange() {
|
||||
val held0 = frag(3, listOf(membership(0)))
|
||||
val set =
|
||||
ConcordListFragmentSet.of(
|
||||
listOf(
|
||||
ConcordListFragmentSet.Copy(0, 100, "a", held0),
|
||||
ConcordListFragmentSet.Copy(2, 100, "c", frag(3, listOf(membership(2)))),
|
||||
),
|
||||
)
|
||||
assertFalse(set.complete)
|
||||
// Leave membership 2: a tombstone lands in fragment 2 only; fragment 1 (unseen) is untouched.
|
||||
val next = ConcordListFragments.mergeDocs(set.doc, doc(tombstones = listOf(tomb(h("member2"), 5000))))
|
||||
val writes = set.planWrites(next, now = 1000)
|
||||
assertEquals(listOf(2), writes.map { it.index })
|
||||
val written = ConcordListFragments.decodeFragment(writes.single().plaintext)
|
||||
assertEquals(3, written.frags, "a scoped write never changes the count")
|
||||
assertEquals(0, written.doc["entries"]!!.jsonArray.size)
|
||||
assertEquals(1, written.doc["tombstones"]!!.jsonArray.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anIncompleteListPutsANewMembershipInTheLowestHeldFragment() {
|
||||
val set =
|
||||
ConcordListFragmentSet.of(
|
||||
listOf(ConcordListFragmentSet.Copy(1, 100, "b", frag(2, listOf(membership(1))))),
|
||||
)
|
||||
val next = ConcordListFragments.mergeDocs(set.doc, doc(listOf(membership(9))))
|
||||
val writes = set.planWrites(next, now = 1000)
|
||||
assertEquals(listOf(1), writes.map { it.index })
|
||||
assertEquals(
|
||||
2,
|
||||
ConcordListFragments
|
||||
.decodeFragment(writes.single().plaintext)
|
||||
.doc["entries"]!!
|
||||
.jsonArray.size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnchangedListWritesNothing() {
|
||||
val set = ConcordListFragmentSet.of(listOf(ConcordListFragmentSet.Copy(0, 100, "a", ConcordListFragments.pack(doc(listOf(membership(0)))).single())))
|
||||
assertEquals(emptyList(), set.planWrites(set.doc, now = 1000).map { it.index })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTypedLayerTombstonesOnLeave() {
|
||||
val entry = ConcordCommunityList.decodeDocument(doc(listOf(membership(0)))).entries.single()
|
||||
val residue = ConcordListResidue.EMPTY.withTombstone(entry.id, 5000)
|
||||
val internal = ConcordCommunityList.encodeInternal(emptyList(), residue)
|
||||
val back = ConcordCommunityList.decodeDocument(internal)
|
||||
assertEquals(emptyList(), back.entries)
|
||||
assertEquals(
|
||||
"5000",
|
||||
back.residue.tombstones
|
||||
.single()["removed_at"]!!
|
||||
.jsonPrimitive.content,
|
||||
)
|
||||
// An earlier removal never lowers the tombstone.
|
||||
assertEquals(residue, residue.withTombstone(entry.id, 10))
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* 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.cord03Channels
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* CORD-01/02/03 Chat Plane conformance: the strict binding (I13), the `ms` tag (I15), the
|
||||
* four-element inline quote (I16), the in-stream delete (S3), the Chat ingest gate (S9) and the
|
||||
* private-channel keying (S2), each pinned against the spec text and examples.md §2.
|
||||
*/
|
||||
class ChannelChatConformanceTest {
|
||||
private val communityRoot = ByteArray(32) { 0x5A }
|
||||
private val channelId = ByteArray(32) { 0x42 }
|
||||
private val channelIdHex = channelId.toHexKey()
|
||||
private val author = KeyPair().pubKey.toHexKey()
|
||||
|
||||
private fun rumorWithTags(vararg tags: Array<String>): Event = RumorAssembler.assembleRumor<Event>(author, 1_700_000_000L, 9, arrayOf(*tags), "x")
|
||||
|
||||
// ---- I13 strict binding -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun bindingRequiresExactlyOneChannelAndOneCanonicalEpoch() {
|
||||
val ok = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", "4"))
|
||||
assertTrue(ok.tags.isConcordBoundTo(channelIdHex, 4))
|
||||
|
||||
// Non-canonical spellings of 4 are a different binding (CORD-01 Encoding: no leading zeros).
|
||||
for (spelling in listOf("04", "+4", " 4", "4 ", "4.0", "0x4")) {
|
||||
val bad = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", spelling))
|
||||
assertFalse(bad.tags.isConcordBoundTo(channelIdHex, 4), "epoch \"$spelling\" must not bind to 4")
|
||||
assertNull(bad.tags.concordEpoch(), "epoch \"$spelling\" must not parse")
|
||||
}
|
||||
assertNull(EpochTag.parse(arrayOf("epoch", "-1")))
|
||||
assertEquals(0L, EpochTag.parse(arrayOf("epoch", "0")))
|
||||
|
||||
// A duplicated tag is ambiguous, even when both copies agree.
|
||||
val dupChannel = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("channel", channelIdHex), arrayOf("epoch", "4"))
|
||||
assertFalse(dupChannel.tags.isConcordBoundTo(channelIdHex, 4))
|
||||
assertNull(dupChannel.tags.concordChannel())
|
||||
val dupEpoch = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", "4"), arrayOf("epoch", "4"))
|
||||
assertFalse(dupEpoch.tags.isConcordBoundTo(channelIdHex, 4))
|
||||
assertNull(dupEpoch.tags.concordEpoch())
|
||||
// A valueless duplicate still counts as a second binding tag (Armada's uniqueTag).
|
||||
val shortDup = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("channel"), arrayOf("epoch", "4"))
|
||||
assertFalse(shortDup.tags.isConcordBoundTo(channelIdHex, 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extraTagsCannotSmuggleASecondBinding() {
|
||||
val rumor =
|
||||
ChannelChat.message(
|
||||
author,
|
||||
channelIdHex,
|
||||
0,
|
||||
"hi",
|
||||
createdAt = 1L,
|
||||
extraTags = arrayOf(arrayOf("channel", "00".repeat(32)), arrayOf("epoch", "9"), arrayOf("ms", "5"), arrayOf("emoji", "a", "u1"), arrayOf("emoji", "b", "u2")),
|
||||
)
|
||||
assertTrue(ChannelChat.isBoundTo(rumor, channelIdHex, 0))
|
||||
assertEquals(1, rumor.tags.count { it[0] == "ms" })
|
||||
// Every extra (non-binding) tag survives, including repeated names.
|
||||
assertEquals(2, rumor.tags.count { it[0] == "emoji" })
|
||||
}
|
||||
|
||||
// ---- I15 ms tag ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun everyChatBuilderStampsAWellFormedMsTag() {
|
||||
val parent = ChannelChat.message(author, channelIdHex, 0, "root", createdAt = 1L, ms = 417)
|
||||
val built =
|
||||
listOf(
|
||||
parent,
|
||||
ChannelChat.inlineReply(author, channelIdHex, 0, "q", parent.id, parent.pubKey, 2L),
|
||||
ChannelChat.reply(author, channelIdHex, 0, "t", parent, 3L),
|
||||
ChannelChat.reaction(author, channelIdHex, 0, parent.id, parent.pubKey, 9, "+", 4L),
|
||||
ChannelChat.edit(author, channelIdHex, 0, parent.id, "e", 5L),
|
||||
ChannelChat.delete(author, channelIdHex, 0, listOf(parent), 6L),
|
||||
ChannelChat.typing(author, channelIdHex, 0, 7L),
|
||||
ChannelChat.imageMessage(author, channelIdHex, 0, "i", emptyList(), 8L),
|
||||
)
|
||||
for (rumor in built) {
|
||||
val ms = rumor.tags.filter { it[0] == "ms" }
|
||||
assertEquals(1, ms.size, "kind ${rumor.kind} must carry exactly one ms tag")
|
||||
assertNotNull(MsTag.parse(ms.single()), "kind ${rumor.kind} ms tag must be well formed")
|
||||
}
|
||||
// The examples' order: channel, epoch, ms first.
|
||||
assertContentEquals(arrayOf("channel", channelIdHex), parent.tags[0])
|
||||
assertContentEquals(arrayOf("epoch", "0"), parent.tags[1])
|
||||
assertContentEquals(arrayOf("ms", "417"), parent.tags[2])
|
||||
assertEquals(1_417L, ChannelChat.orderingMs(parent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun msParsingIsStrictAndOrderingUsesTheMillisecondBasis() {
|
||||
assertEquals(0, MsTag.parse(arrayOf("ms", "0")))
|
||||
assertEquals(999, MsTag.parse(arrayOf("ms", "999")))
|
||||
for (bad in listOf("1000", "-1", "007", "+5", " 5", "1e2", "0x1f", "")) {
|
||||
assertNull(MsTag.parse(arrayOf("ms", bad)), "ms \"$bad\" is malformed")
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> { MsTag.assemble(1000) }
|
||||
|
||||
assertEquals(5_000L, MsTag.orderingMs(5, emptyArray())) // absent = 0
|
||||
assertEquals(5_123L, MsTag.orderingMs(5, arrayOf(arrayOf("ms", "123"))))
|
||||
assertNull(MsTag.orderingMs(5, arrayOf(arrayOf("ms", "1000"))))
|
||||
assertNull(MsTag.orderingMs(5, arrayOf(arrayOf("ms", "1"), arrayOf("ms", "2"))))
|
||||
|
||||
// Same second, the ms remainder decides the order.
|
||||
val early = ChannelChat.message(author, channelIdHex, 0, "a", createdAt = 10L, ms = 900)
|
||||
val late = ChannelChat.message(author, channelIdHex, 0, "b", createdAt = 11L, ms = 5)
|
||||
val mid = ChannelChat.message(author, channelIdHex, 0, "c", createdAt = 10L, ms = 950)
|
||||
assertEquals(listOf("a", "c", "b"), listOf(late, mid, early).sortedBy { ChannelChat.orderingMs(it) }.map { it.content })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun msRemainderTracksTheCurrentSecondOnly() {
|
||||
assertEquals(0, MsTag.remainderFor(1L))
|
||||
val r = MsTag.remainderFor(TimeUtils.now())
|
||||
assertTrue(r in 0..999)
|
||||
}
|
||||
|
||||
// ---- I16 inline quote ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun inlineQuoteUsesTheFourElementQTag() {
|
||||
val parentAuthor = KeyPair().pubKey.toHexKey()
|
||||
val quote = ChannelChat.inlineReply(author, channelIdHex, 0, "Welcome!", "ab".repeat(32), parentAuthor, 2L)
|
||||
val q = quote.tags.single { it[0] == "q" }
|
||||
assertContentEquals(arrayOf("q", "ab".repeat(32), "", parentAuthor), q)
|
||||
assertEquals(9, quote.kind)
|
||||
}
|
||||
|
||||
// ---- S3 delete ----------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun deleteIsAChannelBoundKind5WithETagsThenKTags() =
|
||||
runTest {
|
||||
val alice = NostrSignerInternal(KeyPair())
|
||||
val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0)
|
||||
val message = ChannelChat.message(alice.pubKey, channelIdHex, 0, "oops", createdAt = 1L)
|
||||
val reply = ChannelChat.reply(alice.pubKey, channelIdHex, 0, "also oops", message, 2L)
|
||||
val reaction = ChannelChat.reaction(alice.pubKey, channelIdHex, 0, message.id, message.pubKey, 9, "+", 3L)
|
||||
|
||||
val delete = ChannelChat.delete(alice.pubKey, channelIdHex, 0, listOf(message, reply, reaction), 4L, ms = 533)
|
||||
assertEquals(5, delete.kind)
|
||||
assertEquals("", delete.content)
|
||||
assertContentEquals(arrayOf("channel", channelIdHex), delete.tags[0])
|
||||
assertContentEquals(arrayOf("epoch", "0"), delete.tags[1])
|
||||
assertContentEquals(arrayOf("ms", "533"), delete.tags[2])
|
||||
assertEquals(listOf(message.id, reply.id, reaction.id), delete.tags.filter { it[0] == "e" }.map { it[1] })
|
||||
assertEquals(listOf("9", "1111", "7"), delete.tags.filter { it[0] == "k" }.map { it[1] })
|
||||
|
||||
// It rides the channel plane in an encrypted seal like any other Chat rumor.
|
||||
val wrap = ConcordStreamEnvelope.wrap(delete, channel, alice, encrypted = true)
|
||||
val opened = ConcordStreamEnvelope.open(wrap, channel)
|
||||
assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, opened.sealKind)
|
||||
assertNotNull(ChannelChat.acceptOpened(opened, channelIdHex, 0))
|
||||
assertFailsWith<IllegalArgumentException> { ChannelChat.delete(alice.pubKey, channelIdHex, 0, emptyList(), 4L) }
|
||||
}
|
||||
|
||||
// ---- S9 chat ingest gate ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun chatIngestAcceptsOnlyEncryptedSealsAndChatKinds() =
|
||||
runTest {
|
||||
val alice = NostrSignerInternal(KeyPair())
|
||||
val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0)
|
||||
|
||||
suspend fun open(
|
||||
rumor: Event,
|
||||
encrypted: Boolean = true,
|
||||
) = ConcordStreamEnvelope.open(ConcordStreamEnvelope.wrap(rumor, channel, alice, encrypted = encrypted), channel)
|
||||
|
||||
val message = ChannelChat.message(alice.pubKey, channelIdHex, 0, "hi", createdAt = 1L)
|
||||
assertNotNull(ChannelChat.acceptOpened(open(message), channelIdHex, 0))
|
||||
|
||||
// A plaintext seal is Control-only (CORD-02 §5).
|
||||
assertNull(ChannelChat.acceptOpened(open(message, encrypted = false), channelIdHex, 0))
|
||||
|
||||
// Another plane's kind, correctly bound, is still refused (Armada PLANE_KINDS).
|
||||
for (kind in listOf(3308, 3306, 3309, 3312, 3303, 3313, 1)) {
|
||||
val foreign = RumorAssembler.assembleRumor<Event>(alice.pubKey, 1L, kind, arrayOf(arrayOf("channel", channelIdHex), arrayOf("epoch", "0")), "{}")
|
||||
assertNull(ChannelChat.acceptOpened(open(foreign), channelIdHex, 0), "kind $kind must not enter a Chat Plane")
|
||||
}
|
||||
for (kind in listOf(9, 1111, 7, 5, 3302, 23311, 1740)) {
|
||||
assertTrue(ChannelChat.isChatKind(kind), "kind $kind is a Chat kind")
|
||||
}
|
||||
|
||||
// A malformed ms drops the rumor instead of being interpreted.
|
||||
val badMs = RumorAssembler.assembleRumor<Event>(alice.pubKey, 1L, 9, arrayOf(arrayOf("channel", channelIdHex), arrayOf("epoch", "0"), arrayOf("ms", "1500")), "hi")
|
||||
assertNull(ChannelChat.acceptOpened(open(badMs), channelIdHex, 0))
|
||||
|
||||
// And a binding mismatch is dropped as before.
|
||||
assertNull(ChannelChat.acceptOpened(open(message), channelIdHex, 1))
|
||||
}
|
||||
|
||||
// ---- S2 private channel keying ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aPrivateChannelLivesOnItsOwnKeyNotTheRootPlane() {
|
||||
val channelKey = ByteArray(32) { 0x33 }
|
||||
val public = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0)
|
||||
val private = ConcordChannelKeys.privateChannel(channelKey, channelId, 1)
|
||||
assertNotEquals(public.publicKeyHex, private.publicKeyHex)
|
||||
// The channel epoch is part of the derivation: a stale key generation is a different plane.
|
||||
assertNotEquals(private.publicKeyHex, ConcordChannelKeys.privateChannel(channelKey, channelId, 2).publicKeyHex)
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.cord03Channels
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConcordDisappearingTest {
|
||||
private val author = KeyPair().pubKey.toHexKey()
|
||||
private val channel = "cc".repeat(32)
|
||||
|
||||
@Test
|
||||
fun onlyDurableChatKindsExpire() {
|
||||
assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(9, 1_000, 86_400))
|
||||
assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(1111, 1_000, 86_400))
|
||||
assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(7, 1_000, 86_400))
|
||||
assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(3302, 1_000, 86_400))
|
||||
// Deletes and notices never expire; ephemeral kinds carry nothing; an unset timer tags nothing.
|
||||
assertNull(ConcordDisappearing.expirationFor(5, 1_000, 86_400))
|
||||
assertNull(ConcordDisappearing.expirationFor(1740, 1_000, 86_400))
|
||||
assertNull(ConcordDisappearing.expirationFor(23311, 1_000, 86_400))
|
||||
assertNull(ConcordDisappearing.expirationFor(9, 1_000, null))
|
||||
assertNull(ConcordDisappearing.expirationFor(9, 1_000, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theRumorsOwnTagDecidesExpiry() {
|
||||
val tags = ConcordDisappearing.withExpiration(arrayOf(arrayOf("channel", channel)), 2_000)
|
||||
val rumor = ChannelChat.message(author, channel, 0, "gone soon", 1_000, extraTags = tags)
|
||||
assertEquals(2_000L, ConcordDisappearing.expirationOf(rumor))
|
||||
assertFalse(ConcordDisappearing.isExpired(rumor, now = 1_999))
|
||||
assertTrue(ConcordDisappearing.isExpired(rumor, now = 2_000), "NIP-40: exp <= now")
|
||||
assertFalse(ConcordDisappearing.isExpired(ChannelChat.message(author, channel, 0, "forever", 1_000), now = Long.MAX_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timerNoticeRoundTripsAndRejectsGarbage() {
|
||||
val notice = ConcordDisappearing.timerNotice(author, channel, 4, 2_592_000, 10)
|
||||
assertEquals(1740, notice.kind)
|
||||
assertEquals("", notice.content)
|
||||
assertEquals(channel, ChannelChat.channelOf(notice))
|
||||
assertEquals(4L, ChannelChat.epochOf(notice))
|
||||
assertEquals(2_592_000L, ConcordDisappearing.noticeTimerSecs(notice))
|
||||
assertEquals(0L, ConcordDisappearing.noticeTimerSecs(ConcordDisappearing.timerNotice(author, channel, 4, 0, 10)))
|
||||
|
||||
val bad = ChannelChat.message(author, channel, 4, "", 10, extraTags = arrayOf(arrayOf("timer", "04")))
|
||||
assertNull(ConcordDisappearing.noticeTimerSecs(bad), "not a 1740")
|
||||
}
|
||||
}
|
||||
+98
-35
@@ -50,6 +50,7 @@ class AuthorityResolverTest {
|
||||
json: String,
|
||||
) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0)
|
||||
|
||||
/** A genesis Grant of [member] at their own coordinate; [grantId] only names the rumor. */
|
||||
private fun grant(
|
||||
grantId: String,
|
||||
member: String,
|
||||
@@ -57,7 +58,7 @@ class AuthorityResolverTest {
|
||||
granter: String,
|
||||
) = ControlEdition(
|
||||
ControlEntityKind.GRANT,
|
||||
grantId.hexToByteArray(),
|
||||
ControlFixtures.grantEid(member).hexToByteArray(),
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
@@ -75,7 +76,7 @@ class AuthorityResolverTest {
|
||||
vararg banned: String,
|
||||
) = ControlEdition(
|
||||
ControlEntityKind.BANLIST,
|
||||
"44".repeat(32).hexToByteArray(),
|
||||
ControlFixtures.banlistEid().hexToByteArray(),
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
@@ -106,7 +107,7 @@ class AuthorityResolverTest {
|
||||
)
|
||||
|
||||
val r =
|
||||
AuthorityResolver.resolve(
|
||||
ControlFixtures.resolve(
|
||||
listOf(
|
||||
role(adminRole, adminJson), // position 1, owner-authored
|
||||
modV0, // position 5, owner-authored
|
||||
@@ -130,7 +131,7 @@ class AuthorityResolverTest {
|
||||
// strip anyone, the owner's admins included. Armada gates this the same way: a grant is an
|
||||
// action ON the member, so demotion must be at least as hard as promotion.
|
||||
val modWithManageRoles = """{"name":"Mod","position":5,"permissions":"9"}""" // KICK|MANAGE_ROLES
|
||||
val adminGrantId = "31".repeat(32)
|
||||
val adminGrantId = ControlFixtures.grantEid(alice)
|
||||
val adminGrant = grant(adminGrantId, alice, listOf(adminRole), granter = owner)
|
||||
val revokeByMod =
|
||||
ControlEdition(
|
||||
@@ -146,7 +147,7 @@ class AuthorityResolverTest {
|
||||
)
|
||||
|
||||
val r =
|
||||
AuthorityResolver.resolve(
|
||||
ControlFixtures.resolve(
|
||||
listOf(
|
||||
role(adminRole, adminJson), // position 1
|
||||
role(modRole, modWithManageRoles), // position 5
|
||||
@@ -163,7 +164,7 @@ class AuthorityResolverTest {
|
||||
@Test
|
||||
fun anAdminCanStillRevokeAMemberBeneathIt() {
|
||||
// The gate must not block legitimate moderation: an admin may revoke a moderator's roles.
|
||||
val modGrantId = "32".repeat(32)
|
||||
val modGrantId = ControlFixtures.grantEid(bob)
|
||||
val modGrant = grant(modGrantId, bob, listOf(modRole), granter = owner)
|
||||
val revokeByAdmin =
|
||||
ControlEdition(
|
||||
@@ -179,7 +180,7 @@ class AuthorityResolverTest {
|
||||
)
|
||||
|
||||
val r =
|
||||
AuthorityResolver.resolve(
|
||||
ControlFixtures.resolve(
|
||||
listOf(
|
||||
role(adminRole, adminJson),
|
||||
role(modRole, modJson),
|
||||
@@ -211,7 +212,7 @@ class AuthorityResolverTest {
|
||||
)
|
||||
|
||||
val r =
|
||||
AuthorityResolver.resolve(
|
||||
ControlFixtures.resolve(
|
||||
listOf(
|
||||
role(adminRole, adminJson),
|
||||
modV0,
|
||||
@@ -235,7 +236,7 @@ class AuthorityResolverTest {
|
||||
grant("ba".repeat(32), bob, listOf(modRole), granter = alice),
|
||||
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
val r = ControlFixtures.resolve(heads, owner)
|
||||
|
||||
assertTrue(r.isOwner(owner))
|
||||
assertEquals(0L, r.rank(owner))
|
||||
@@ -263,7 +264,7 @@ class AuthorityResolverTest {
|
||||
// carol has no authority, so her grant to dave is dropped
|
||||
grant("cd".repeat(32), dave, listOf(adminRole), granter = carol),
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
val r = ControlFixtures.resolve(heads, owner)
|
||||
assertNull(r.rank(dave))
|
||||
assertEquals(ConcordPermissions.NONE.bits, r.effectivePermissions(dave).bits)
|
||||
}
|
||||
@@ -277,7 +278,7 @@ class AuthorityResolverTest {
|
||||
grant("ab".repeat(32), alice, listOf(modRole), granter = owner), // alice is a mod (no MANAGE_ROLES)
|
||||
grant("ae".repeat(32), dave, listOf(adminRole), granter = alice), // mod cannot grant admin
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
val r = ControlFixtures.resolve(heads, owner)
|
||||
assertEquals(5L, r.rank(alice))
|
||||
assertNull(r.rank(dave)) // rejected: alice lacks MANAGE_ROLES and doesn't outrank admin
|
||||
}
|
||||
@@ -290,7 +291,7 @@ class AuthorityResolverTest {
|
||||
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
|
||||
banlist(alice),
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
val r = ControlFixtures.resolve(heads, owner)
|
||||
assertTrue(r.isBanned(alice))
|
||||
// A banned actor can take no action even though the role bit is present.
|
||||
assertFalse(r.hasPermission(alice, BAN))
|
||||
@@ -298,11 +299,11 @@ class AuthorityResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentBansHealIntoAUnionAndAreNeverDropped() {
|
||||
// Two authorized moderators ban different abusers at the same banlist version — a
|
||||
// fork of the single banlist doc. Folding to one chain tip would silently drop the
|
||||
// loser's ban and let that abuser back in; the union keeps both (M1 / CORD-06
|
||||
// down-only healing).
|
||||
fun concurrentBansForkAndTheFoldKeepsOneEdition() {
|
||||
// Two authorized members ban different abusers at the same banlist version — a fork of
|
||||
// the single replaced document. CORD-04 §4: "the fold keeps one edition and the other's
|
||||
// addition drops until re-applied". Authority breaks the tie (§1), so the owner's edition
|
||||
// wins even though alice's rumor id sorts first; a union of forks is NOT the fold.
|
||||
val heads =
|
||||
listOf(
|
||||
role(adminRole, adminJson),
|
||||
@@ -310,9 +311,71 @@ class AuthorityResolverTest {
|
||||
banlistBy(owner, "ban-owner", bob), // owner bans bob
|
||||
banlistBy(alice, "ban-alice", carol), // alice concurrently bans carol
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
val r = ControlFixtures.resolve(heads, owner)
|
||||
assertTrue(r.isBanned(bob), "the owner's edition wins the fork")
|
||||
assertFalse(r.isBanned(carol), "the losing fork's addition drops until re-applied")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLosingBanIsReHealedByReApplyingItAtopTheWinner() {
|
||||
// §4 re-heal: after publishing, re-fold, and if your addition isn't in the head, re-apply
|
||||
// it on top of the winner. That converges on the union without the fold ever unioning.
|
||||
val ownerFork = banlistBy(owner, "ban-owner", bob)
|
||||
val base =
|
||||
listOf(
|
||||
role(adminRole, adminJson),
|
||||
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
|
||||
ownerFork,
|
||||
banlistBy(alice, "ban-alice", carol),
|
||||
)
|
||||
val reHeal =
|
||||
ControlEdition(
|
||||
ControlEntityKind.BANLIST,
|
||||
ControlFixtures.banlistEid().hexToByteArray(),
|
||||
1,
|
||||
ownerFork.hash, // atop the winner
|
||||
null,
|
||||
"[\"$bob\",\"$carol\"]",
|
||||
alice,
|
||||
"ban-alice-reheal",
|
||||
1,
|
||||
)
|
||||
val r = ControlFixtures.resolve(base + reHeal, owner)
|
||||
assertTrue(r.isBanned(bob))
|
||||
assertTrue(r.isBanned(carol))
|
||||
assertTrue(r.isBanned(carol), "re-applied atop the winner, alice's ban lands")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBanOnALosingForkNeverSticks() {
|
||||
// The union's worst failure: a ban that lost the fork could never be lifted, because no
|
||||
// later edition supersedes a fork. Folding to one head, the losing fork is simply inert, and
|
||||
// an unban chained onto the head sticks.
|
||||
val ownerV0 = banlistBy(owner, "ban-owner", bob)
|
||||
val unban =
|
||||
ControlEdition(ControlEntityKind.BANLIST, ControlFixtures.banlistEid().hexToByteArray(), 1, ownerV0.hash, null, "[]", owner, "unban", 1)
|
||||
val r =
|
||||
ControlFixtures.resolve(
|
||||
listOf(
|
||||
role(adminRole, adminJson),
|
||||
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
|
||||
ownerV0,
|
||||
banlistBy(alice, "ban-alice", dave), // a v0 fork that loses to the owner
|
||||
unban,
|
||||
),
|
||||
owner,
|
||||
)
|
||||
assertFalse(r.isBanned(bob), "the owner's unban applies")
|
||||
assertFalse(r.isBanned(dave), "and the losing fork's ban never took effect")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBanlistAtAnyOtherCoordinateIsIgnored() {
|
||||
// The Banlist lives at banlist_locator(community_id) (CORD-02 A.6). A list at any other eid
|
||||
// is not this community's Banlist, even owner-signed.
|
||||
val stray =
|
||||
ControlEdition(ControlEntityKind.BANLIST, "44".repeat(32).hexToByteArray(), 0, null, null, "[\"$bob\"]", owner, "stray", 0)
|
||||
val r = ControlFixtures.resolve(listOf(role(adminRole, adminJson), stray), owner)
|
||||
assertFalse(r.isBanned(bob))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -323,7 +386,7 @@ class AuthorityResolverTest {
|
||||
role(adminRole, adminJson),
|
||||
banlistBy(carol, "ban-carol", dave),
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
val r = ControlFixtures.resolve(heads, owner)
|
||||
assertFalse(r.isBanned(dave))
|
||||
}
|
||||
|
||||
@@ -335,7 +398,7 @@ class AuthorityResolverTest {
|
||||
role(modRole, """{"name":"Peer","position":0,"permissions":"25"}"""), // illegal position 0
|
||||
grant("ab".repeat(32), alice, listOf(adminRole, modRole), granter = owner),
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
val r = ControlFixtures.resolve(heads, owner)
|
||||
assertNull(r.rank(alice)) // both assigned roles are invalid
|
||||
}
|
||||
|
||||
@@ -352,7 +415,7 @@ class AuthorityResolverTest {
|
||||
role(adminRole, """{"name":"Admin","position":1,"permissions":"25","scope":{"kind":"server"},"color":0}"""),
|
||||
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner),
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
val r = ControlFixtures.resolve(heads, owner)
|
||||
assertEquals(1L, r.rank(alice))
|
||||
assertTrue(r.effectivePermissions(alice).has(BAN))
|
||||
}
|
||||
@@ -365,7 +428,7 @@ class AuthorityResolverTest {
|
||||
*/
|
||||
@Test
|
||||
fun rogueHigherVersionGrantCannotSupersedeALegitGrant() {
|
||||
val grantId = "ab".repeat(32)
|
||||
val grantId = ControlFixtures.grantEid(alice)
|
||||
val ownerGrant = grant(grantId, alice, listOf(adminRole), granter = owner) // v0, prev null
|
||||
val rogueV1 =
|
||||
ControlEdition(
|
||||
@@ -379,7 +442,7 @@ class AuthorityResolverTest {
|
||||
"grant-$grantId-rogue",
|
||||
1,
|
||||
)
|
||||
val r = AuthorityResolver.resolve(listOf(role(adminRole, adminJson), ownerGrant, rogueV1), owner)
|
||||
val r = ControlFixtures.resolve(listOf(role(adminRole, adminJson), ownerGrant, rogueV1), owner)
|
||||
assertEquals(1L, r.rank(alice)) // rogue v1 dropped; the owner's v0 grant stands
|
||||
}
|
||||
|
||||
@@ -411,7 +474,7 @@ class AuthorityResolverTest {
|
||||
*/
|
||||
@Test
|
||||
fun anUnauthorizedEditionMidChainDoesNotOrphanTheHonestEditionsAboveIt() {
|
||||
val grantId = "ab".repeat(32)
|
||||
val grantId = ControlFixtures.grantEid(alice)
|
||||
val v0 = edition(ControlEntityKind.GRANT, grantId, 0, null, grantJson(listOf(modRole)), owner, "g0")
|
||||
val v1 = edition(ControlEntityKind.GRANT, grantId, 1, v0, grantJson(listOf(adminRole)), owner, "g1")
|
||||
// carol holds ZERO roles — correctly rejected, at any position in the chain.
|
||||
@@ -421,7 +484,7 @@ class AuthorityResolverTest {
|
||||
val v5 = edition(ControlEntityKind.GRANT, grantId, 5, v4, grantJson(listOf(modRole)), owner, "g5")
|
||||
|
||||
val r =
|
||||
AuthorityResolver.resolve(
|
||||
ControlFixtures.resolve(
|
||||
listOf(role(adminRole, adminJson), role(modRole, modJson), v0, v1, v2, v3, v4, v5),
|
||||
owner,
|
||||
)
|
||||
@@ -446,7 +509,7 @@ class AuthorityResolverTest {
|
||||
val v4 = edition(ControlEntityKind.ROLE, modRole, 4, v3, mod(7, "8"), owner, "r4")
|
||||
|
||||
val r =
|
||||
AuthorityResolver.resolve(
|
||||
ControlFixtures.resolve(
|
||||
listOf(role(adminRole, adminJson), v0, v1, v2, v3, v4, grant("32".repeat(32), bob, listOf(modRole), granter = owner)),
|
||||
owner,
|
||||
)
|
||||
@@ -463,7 +526,7 @@ class AuthorityResolverTest {
|
||||
*/
|
||||
@Test
|
||||
fun anUnauthorizedBanlistEditionMidChainDoesNotOrphanOrResurrectBans() {
|
||||
val banId = "44".repeat(32)
|
||||
val banId = ControlFixtures.banlistEid()
|
||||
|
||||
fun list(vararg keys: String) = "[${keys.joinToString(",") { "\"$it\"" }}]"
|
||||
|
||||
@@ -472,7 +535,7 @@ class AuthorityResolverTest {
|
||||
val v2 = edition(ControlEntityKind.BANLIST, banId, 2, v1, list(), dave, "b2") // dave holds no BAN
|
||||
val v3 = edition(ControlEntityKind.BANLIST, banId, 3, v2, list(carol), owner, "b3") // owner unbans bob
|
||||
|
||||
val r = AuthorityResolver.resolve(listOf(role(adminRole, adminJson), v0, v1, v2, v3), owner)
|
||||
val r = ControlFixtures.resolve(listOf(role(adminRole, adminJson), v0, v1, v2, v3), owner)
|
||||
|
||||
assertTrue(r.isBanned(carol), "carol's ban survives to the head")
|
||||
assertFalse(r.isBanned(bob), "the owner's v3 unban must apply — v2 may not orphan it")
|
||||
@@ -481,7 +544,7 @@ class AuthorityResolverTest {
|
||||
/** A forged edition takes effect at NO position: not at the tip, and not mid-chain. */
|
||||
@Test
|
||||
fun aForgedEditionNeverTakesEffectAtAnyPosition() {
|
||||
val grantId = "ab".repeat(32)
|
||||
val grantId = ControlFixtures.grantEid(alice)
|
||||
val v0 = edition(ControlEntityKind.GRANT, grantId, 0, null, grantJson(listOf(adminRole)), owner, "g0")
|
||||
// carol holds nothing; her revoke is the forgery, and it must apply at NO position.
|
||||
val forgedV1 = edition(ControlEntityKind.GRANT, grantId, 1, v0, grantJson(emptyList()), carol, "g1")
|
||||
@@ -490,18 +553,18 @@ class AuthorityResolverTest {
|
||||
|
||||
// Mid-chain: the honest v2 above it still resolves (this arm needs the fix), and the
|
||||
// forged revoke never empties alice's roles.
|
||||
val mid = AuthorityResolver.resolve(base + listOf(v0, forgedV1, v2), owner)
|
||||
val mid = ControlFixtures.resolve(base + listOf(v0, forgedV1, v2), owner)
|
||||
assertEquals(setOf(modRole), mid.rolesOf(alice))
|
||||
assertEquals(5L, mid.rank(alice))
|
||||
|
||||
// At the tip: the chain-verified head fails the gate, so the fold falls back to v0.
|
||||
val tip = AuthorityResolver.resolve(base + listOf(v0, forgedV1), owner)
|
||||
val tip = ControlFixtures.resolve(base + listOf(v0, forgedV1), owner)
|
||||
assertEquals(setOf(adminRole), tip.rolesOf(alice), "the forged revoke must not strip alice")
|
||||
assertEquals(1L, tip.rank(alice))
|
||||
|
||||
// Above the tip, dangling: a higher version is never a shortcut past the gate.
|
||||
val danglingV9 = edition(ControlEntityKind.GRANT, grantId, 9, null, grantJson(emptyList()), carol, "g9")
|
||||
val above = AuthorityResolver.resolve(base + listOf(v0, danglingV9), owner)
|
||||
val above = ControlFixtures.resolve(base + listOf(v0, danglingV9), owner)
|
||||
assertEquals(setOf(adminRole), above.rolesOf(alice))
|
||||
assertEquals(1L, above.rank(alice))
|
||||
}
|
||||
@@ -522,7 +585,7 @@ class AuthorityResolverTest {
|
||||
private val modWithBanJson = """{"name":"Mod","position":5,"permissions":"24"}""" // KICK|BAN
|
||||
|
||||
private fun rankedBanScenario(vararg extra: ControlEdition) =
|
||||
AuthorityResolver.resolve(
|
||||
ControlFixtures.resolve(
|
||||
listOf(
|
||||
role(adminRole, adminJson), // position 1
|
||||
role(modRole, modWithBanJson), // position 5, holds BAN
|
||||
@@ -592,7 +655,7 @@ class AuthorityResolverTest {
|
||||
// PIN_MESSAGES alone (bit 11 = 2048) writes Control editions, so it is a staff bit.
|
||||
val pinJson = """{"name":"Curator","position":6,"permissions":"2048"}"""
|
||||
val r =
|
||||
AuthorityResolver.resolve(
|
||||
ControlFixtures.resolve(
|
||||
listOf(
|
||||
role(adminRole, adminJson), // MANAGE_ROLES|KICK|BAN → staff via MANAGE_ROLES/BAN
|
||||
role(modRole, modJson), // KICK only → Guestbook writer, NOT staff
|
||||
|
||||
+34
-34
@@ -46,8 +46,8 @@ import kotlin.test.assertTrue
|
||||
* [AuthorityResolver.resolve]. Note that a chain-local rule ("the author must not be banned by the
|
||||
* state their edition chains from") would NOT have been enough:
|
||||
* [aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan] forks at genesis so no
|
||||
* parent ever mentions the ban, and CORD-04 §4's re-heal union would carry it in anyway. The rule
|
||||
* had to bind the union too, which is why it is expressed as a whole-pass mask.
|
||||
* parent ever mentions the ban. The rule had to bind every fork too, which is why it is expressed as
|
||||
* a whole-pass mask.
|
||||
*
|
||||
* Three tests pin behaviour the fix had to *preserve* rather than change:
|
||||
* [selfUnbanIsStillRefused], [aJuniorPuppetCannotLiftASeniorsBan], and
|
||||
@@ -65,11 +65,11 @@ class BannedStaffEscalationTest {
|
||||
private val modRole = "22".repeat(32)
|
||||
private val puppetRole = "33".repeat(32)
|
||||
|
||||
private val banlistEntity = "44".repeat(32)
|
||||
private val banlistEntity = ControlFixtures.banlistEid()
|
||||
private val channelEntity = "55".repeat(32)
|
||||
private val metadataEntity = "66".repeat(32)
|
||||
private val bobGrantEntity = "32".repeat(32)
|
||||
private val puppetGrantEntity = "35".repeat(32)
|
||||
private val metadataEntity = ControlFixtures.COMMUNITY_ID_HEX
|
||||
private val bobGrantEntity = ControlFixtures.grantEid(bob)
|
||||
private val puppetGrantEntity = ControlFixtures.grantEid(puppet)
|
||||
|
||||
// MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|KICK|BAN|CREATE_INVITE = 1+2+4+8+16+64
|
||||
private val adminJson = """{"name":"Admin","position":1,"permissions":"95"}"""
|
||||
@@ -102,7 +102,8 @@ class BannedStaffEscalationTest {
|
||||
prev: ByteArray? = null,
|
||||
) = edition(
|
||||
ControlEntityKind.GRANT,
|
||||
coordinate,
|
||||
// A Grant lives at its member's own coordinate (CORD-04 §1); [coordinate] only names the rumor.
|
||||
ControlFixtures.grantEid(member),
|
||||
version,
|
||||
prev,
|
||||
"""{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""",
|
||||
@@ -167,7 +168,7 @@ class BannedStaffEscalationTest {
|
||||
|
||||
@Test
|
||||
fun aBanStripsTheAuthorityCheckedByFoldButNotTheOneCheckedByTheResolver() {
|
||||
val r = AuthorityResolver.resolve(community() + ownerBansAlice, owner)
|
||||
val r = ControlFixtures.resolve(community() + ownerBansAlice, owner)
|
||||
|
||||
assertTrue(r.isBanned(alice), "the owner's ban lands")
|
||||
assertFalse(r.hasPermission(alice, ConcordPermissions.MANAGE_ROLES), "the ban-aware check refuses her")
|
||||
@@ -182,7 +183,7 @@ class BannedStaffEscalationTest {
|
||||
|
||||
@Test
|
||||
fun aBannedAdminPromotesAFreshSockpuppetToAdmin() {
|
||||
val r = AuthorityResolver.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner)
|
||||
val r = ControlFixtures.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner)
|
||||
|
||||
assertEquals(null, r.rank(puppet), "the banned admin's role and grant editions are both dropped")
|
||||
assertFalse(r.isBanned(puppet), "the puppet itself is a clean npub — it is never banned, just powerless")
|
||||
@@ -200,7 +201,7 @@ class BannedStaffEscalationTest {
|
||||
channel("""{"name":"general","deleted":true}""", puppet, 1, channelV0.hash) +
|
||||
metadata("""{"name":"Owned by the guy you banned"}""", puppet, 1, metadataV0.hash)
|
||||
|
||||
val state = ConcordCommunityState.fold(editions, owner)
|
||||
val state = ControlFixtures.fold(editions, owner)
|
||||
|
||||
assertEquals(1, state.channels.size, "the puppet holds nothing, so its tombstone is inert")
|
||||
assertEquals("My Community", state.metadata?.name, "and the community keeps its identity")
|
||||
@@ -210,7 +211,7 @@ class BannedStaffEscalationTest {
|
||||
fun theSockpuppetBansEveryMemberBeneathIt() {
|
||||
val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash, alice, bob, carol)
|
||||
|
||||
val r = AuthorityResolver.resolve(editions, owner)
|
||||
val r = ControlFixtures.resolve(editions, owner)
|
||||
|
||||
assertFalse(r.isBanned(bob), "the puppet's banlist edition is unauthorized, so the moderator stands")
|
||||
assertFalse(r.isBanned(carol), "and so do the plain members")
|
||||
@@ -220,7 +221,7 @@ class BannedStaffEscalationTest {
|
||||
fun aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll() {
|
||||
val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash, alice, bob, carol)
|
||||
|
||||
val r = AuthorityResolver.resolve(editions, owner)
|
||||
val r = ControlFixtures.resolve(editions, owner)
|
||||
|
||||
assertTrue(r.isBanned(alice), "her own ban stands — it was the owner's")
|
||||
assertFalse(r.isBanned(bob), "banGate now drops a banned author's edition outright")
|
||||
@@ -231,14 +232,14 @@ class BannedStaffEscalationTest {
|
||||
fun aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan() {
|
||||
// The same attack as above, except her edition does NOT chain onto the edition that banned
|
||||
// her — it forks at genesis. So a rule that only asks "was the author banned by this
|
||||
// edition's parent?" never sees her ban, and CORD-04 §4's re-heal union carries her bans in
|
||||
// regardless. Any fix has to bind the union, not just the chain.
|
||||
// edition's parent?" never sees her ban. The equal-version fork now resolves authority
|
||||
// first (CORD-04 §1): the owner's edition takes it whatever the rumor ids say.
|
||||
val editions = community() + ownerBansAlice + banlist(alice, 0, null, bob, carol)
|
||||
|
||||
val r = AuthorityResolver.resolve(editions, owner)
|
||||
val r = ControlFixtures.resolve(editions, owner)
|
||||
|
||||
assertTrue(r.isBanned(alice), "the owner's ban survives the fork — the union is down-only")
|
||||
assertFalse(r.isBanned(bob), "the fix binds the UNION too: her fork is dropped before it can be healed in")
|
||||
assertTrue(r.isBanned(alice), "the owner's ban wins the fork")
|
||||
assertFalse(r.isBanned(bob), "her fork loses, and her authorship is masked besides")
|
||||
assertFalse(r.isBanned(carol), "same")
|
||||
}
|
||||
|
||||
@@ -246,7 +247,7 @@ class BannedStaffEscalationTest {
|
||||
fun aBannedAdminRevokesTheSurvivingModerators() {
|
||||
val editions = community() + ownerBansAlice + grant(bobGrantEntity, bob, emptyList(), author = alice, version = 1, prev = bobGrantV0.hash)
|
||||
|
||||
val r = AuthorityResolver.resolve(editions, owner)
|
||||
val r = ControlFixtures.resolve(editions, owner)
|
||||
|
||||
assertEquals(5, r.rank(bob), "a banned admin's revoke is dropped, so the moderator keeps their role")
|
||||
assertTrue(r.hasPermission(bob, ConcordPermissions.BAN), "and keeps the authority that comes with it")
|
||||
@@ -256,7 +257,7 @@ class BannedStaffEscalationTest {
|
||||
fun aBannedAdminDeletesEveryRoleBeneathThem() {
|
||||
val tombstone = role(modRole, """{"name":"Mod","position":5,"permissions":"24","deleted":true}""", author = alice, version = 1, prev = modRoleV0.hash)
|
||||
|
||||
val r = AuthorityResolver.resolve(community() + ownerBansAlice + tombstone, owner)
|
||||
val r = ControlFixtures.resolve(community() + ownerBansAlice + tombstone, owner)
|
||||
|
||||
assertEquals(5, r.roles()[modRole]?.position, "a banned admin's tombstone is dropped, so the role survives")
|
||||
assertEquals(5, r.rank(bob), "and its holders keep their standing")
|
||||
@@ -268,7 +269,7 @@ class BannedStaffEscalationTest {
|
||||
// gates removals too, and strict outranking means nobody outranks themselves.
|
||||
val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash)
|
||||
|
||||
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a banned member may not lift their own ban")
|
||||
assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "a banned member may not lift their own ban")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -278,7 +279,7 @@ class BannedStaffEscalationTest {
|
||||
// can outrank her, and so nothing she mints can unban her.
|
||||
val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash)
|
||||
|
||||
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator")
|
||||
assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -289,22 +290,21 @@ class BannedStaffEscalationTest {
|
||||
// to win the head fold. The head's own effective list then never carried his ban, so there is
|
||||
// nothing to remove and the rank rule never fires.
|
||||
//
|
||||
// §4's re-heal is what closes it: the owner's edition is authorized and is NOT on the forked
|
||||
// head's back-chain, so it is unioned back in as a concurrent ban.
|
||||
// A dangling high version never wins the fold: the chain anchors at the genesis (the lowest
|
||||
// version without a `prev`), and the chain-verified head outranks every edition off it.
|
||||
val editions = community() + ownerBansAlice + banlist(alice, 99, null, carol)
|
||||
|
||||
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the re-heal union must put the owner's ban back")
|
||||
assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "the owner's chain keeps the head")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPrivateBanlistChainOfHisOwnCannotLaunderTheBanAway() {
|
||||
// The same idea two editions deep, so the winning head has a clean ancestry entirely of his
|
||||
// own making. Ancestry is walked over the full pool, so the owner's ban is still recognised
|
||||
// as a concurrent fork rather than a superseded ancestor.
|
||||
// own making. It is still a chain off the side of the genesis the owner's edition anchors.
|
||||
val mine = banlist(alice, 50, null)
|
||||
val editions = community() + ownerBansAlice + mine + banlist(alice, 51, mine.hash)
|
||||
|
||||
assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a self-authored chain must not launder the ban away")
|
||||
assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "a self-authored chain must not launder the ban away")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -318,15 +318,15 @@ class BannedStaffEscalationTest {
|
||||
// whole defense, so this splits the community in two: clients that already folded the ban
|
||||
// refuse the rollback, while fresh joiners have no floor to refuse with and see no ban at all.
|
||||
val editions = community() + ownerBansAlice
|
||||
val floors = ConcordCommunityState.authorizedHeads(editions, owner)
|
||||
val floors = ControlFixtures.authorizedHeads(editions, owner)
|
||||
val compacted = editions.filter { it.entityKind != ControlEntityKind.BANLIST }
|
||||
|
||||
assertFalse(
|
||||
ConcordCommunityState.fold(compacted, owner).authority.isBanned(alice),
|
||||
ControlFixtures.fold(compacted, owner).authority.isBanned(alice),
|
||||
"ESCALATION: a fresh joiner holds no floor, so the omitted ban simply never existed",
|
||||
)
|
||||
assertTrue(
|
||||
ConcordCommunityState.fold(compacted, owner, floors).authority.isBanned(alice),
|
||||
ControlFixtures.fold(compacted, owner, floors).authority.isBanned(alice),
|
||||
"a client that already folded the ban must refuse the rollback",
|
||||
)
|
||||
}
|
||||
@@ -340,10 +340,10 @@ class BannedStaffEscalationTest {
|
||||
// to re-issue. See B2 in docs/concord-soft-ban-audit.md.
|
||||
val promoted = grant("36".repeat(32), carol, listOf(modRole), author = alice)
|
||||
|
||||
val before = AuthorityResolver.resolve(community() + promoted, owner)
|
||||
val before = ControlFixtures.resolve(community() + promoted, owner)
|
||||
assertEquals(5, before.rank(carol), "while alice is in good standing, her grant stands")
|
||||
|
||||
val after = AuthorityResolver.resolve(community() + promoted + ownerBansAlice, owner)
|
||||
val after = ControlFixtures.resolve(community() + promoted + ownerBansAlice, owner)
|
||||
assertEquals(null, after.rank(carol), "banning alice retroactively drops the grant she authored")
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ class BannedStaffEscalationTest {
|
||||
// only honored when authored by someone who outranks it — the owner does, bob does not.
|
||||
val carolByOwner = grant("38".repeat(32), carol, listOf(modRole), author = owner)
|
||||
|
||||
val r = AuthorityResolver.resolve(community() + ownerBansAlice + carolByBob + carolByOwner, owner)
|
||||
val r = ControlFixtures.resolve(community() + ownerBansAlice + carolByBob + carolByOwner, owner)
|
||||
|
||||
assertTrue(r.isBanned(alice), "alice is the only one banned")
|
||||
assertEquals(5, r.rank(bob), "bob is untouched")
|
||||
@@ -387,7 +387,7 @@ class BannedStaffEscalationTest {
|
||||
// ...while the owner concurrently bans the rogue, never naming bob
|
||||
ownerBansAlice
|
||||
|
||||
val r = AuthorityResolver.resolve(editions, owner)
|
||||
val r = ControlFixtures.resolve(editions, owner)
|
||||
|
||||
assertTrue(r.isBanned(alice), "the owner's ban of the rogue stands")
|
||||
assertFalse(r.isBanned(bob), "and the rogue's ban of the moderator falls with them")
|
||||
|
||||
+75
@@ -21,11 +21,15 @@
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.EditionHash
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
@@ -109,6 +113,77 @@ class ControlEditionTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun parse(vararg tags: Array<String>) = ControlEdition.fromRumor(RumorAssembler.assembleRumor<Event>(author, 1L, ControlEditionEvent.KIND, arrayOf(*tags), "{}"))
|
||||
|
||||
private val cite = arrayOf("vac", ByteArray(32) { 0x02 }.toHexKey(), "2", ByteArray(32) { 0x03 }.toHexKey())
|
||||
|
||||
@Test
|
||||
fun versionTagsMustBeCanonicalDecimals() {
|
||||
// CORD-01 §5: no sign, no leading zeros. toLongOrNull() would read all of these as 4.
|
||||
assertNotNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4")))
|
||||
for (bad in listOf("04", "+4", "-4", "4.0", "0x4", "1e2", "", " 4")) {
|
||||
assertNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", bad)), "ev '$bad'")
|
||||
}
|
||||
assertNotNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0")), "a legacy v0 chain still reads")
|
||||
|
||||
// The vac version too, and a malformed vac rejects the edition rather than reading as "no citation".
|
||||
assertEquals(2L, parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4"), cite)?.authorityCitation?.grantVersion)
|
||||
for (bad in listOf("02", "+2")) {
|
||||
val vac = arrayOf("vac", cite[1], bad, cite[3])
|
||||
assertNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4"), vac), "vac version '$bad'")
|
||||
}
|
||||
|
||||
// And the sub-kind: "03" is not the Grant sub-kind.
|
||||
assertNull(parse(arrayOf("vsk", "03"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDuplicatedMachineryTagMakesTheEditionInvalid() {
|
||||
// Two readers could each take a different copy, so the edition is ambiguous (Armada `parseEdition`).
|
||||
val base = arrayOf(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4"))
|
||||
assertNotNull(parse(*base))
|
||||
assertNull(parse(*base, arrayOf("vsk", "2")), "duplicate vsk")
|
||||
assertNull(parse(*base, arrayOf("eid", ByteArray(32).toHexKey())), "duplicate eid")
|
||||
assertNull(parse(*base, arrayOf("ev", "5")), "duplicate ev")
|
||||
assertNull(parse(*base, arrayOf("ep", ByteArray(32).toHexKey()), arrayOf("ep", ByteArray(32) { 1 }.toHexKey())), "duplicate ep")
|
||||
assertNull(parse(*base, cite, cite), "duplicate vac")
|
||||
// Even a duplicate that fails to parse on its own: "04" beside "4" is still two ev tags.
|
||||
assertNull(parse(*base, arrayOf("ev", "04")), "a second, non-canonical ev")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subKindsThatAreNotControlEditionsAreRefusedAndUnknownOnesKept() {
|
||||
// 6/9 belong to the kind-33301 invite marker, 7 is retired, 10 is the dissolution tombstone.
|
||||
for (vsk in listOf("6", "7", "9", "10")) {
|
||||
assertNull(parse(arrayOf("vsk", vsk), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1")), "vsk $vsk")
|
||||
}
|
||||
// A sub-kind this client does not model (Signals 12, anything newer) is kept, raw.
|
||||
val signals = parse(arrayOf("vsk", "12"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1"))
|
||||
assertNotNull(signals)
|
||||
assertNull(signals.entityKind)
|
||||
assertEquals("12", signals.vsk)
|
||||
// Pins (11) are modeled.
|
||||
assertEquals(ControlEntityKind.PIN_LIST, parse(arrayOf("vsk", "11"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1"))?.entityKind)
|
||||
assertEquals(ControlEntityKind.CHANNEL.wire, parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1"))?.vsk)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEditionUnderAnEncryptedSealIsNotAControlEdition() =
|
||||
runTest {
|
||||
// CORD-02 §5: Control Plane seals MUST be plaintext (20014) — only those survive a
|
||||
// compaction re-wrap with the author's signature intact.
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val plane = ConcordKeyDerivation.controlPlaneKey(ByteArray(32) { 1 }, ByteArray(32) { 2 }, 0)
|
||||
val rumor = ControlEditionBuilder.rumor(signer.pubKey, ControlEntityKind.CHANNEL, eid, 1, null, """{"name":"general"}""", 1L)
|
||||
|
||||
val plaintext = ConcordStreamEnvelope.open(ConcordStreamEnvelope.wrap(rumor, plane, signer, encrypted = false, createdAt = 1L), plane)
|
||||
assertNotNull(ControlEdition.fromOpened(plaintext))
|
||||
|
||||
val encrypted = ConcordStreamEnvelope.open(ConcordStreamEnvelope.wrap(rumor, plane, signer, encrypted = true, createdAt = 1L), plane)
|
||||
assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, encrypted.sealKind)
|
||||
assertNull(ControlEdition.fromOpened(encrypted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genesisHasNullPrevWhenEpAbsent() {
|
||||
val tags = arrayOf(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0"))
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ControlEntityRoundTripTest {
|
||||
private fun parse(json: String): JsonObject = ConcordJson.instance.parseToJsonElement(json).jsonObject
|
||||
|
||||
@Test
|
||||
fun renameCarriesUnknownMetadataFieldsThrough() {
|
||||
// CORD-02 §6: another client's `custom`, a newer protocol field, and the CORD-08 timer must
|
||||
// all survive a rename by a client that models none of them.
|
||||
val head = """{"name":"Old","message_expiration":2592000,"custom":{"rules":"be nice"},"av_brokers":["https://b.example"]}"""
|
||||
val renamed = ConcordJson.decodeOrNull<MetadataEntity>(head)!!.copy(name = "New")
|
||||
val out = parse(ConcordJson.encodePreserving(MetadataEntity.serializer(), renamed, head))
|
||||
assertEquals("New", (out["name"] as JsonPrimitive).content)
|
||||
assertEquals(JsonPrimitive(2592000), out["message_expiration"])
|
||||
assertEquals(parse("""{"rules":"be nice"}"""), out["custom"])
|
||||
assertEquals(parse(head)["av_brokers"], out["av_brokers"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aModeledFieldCanStillBeCleared() {
|
||||
val head = """{"name":"X","description":"gone soon","custom":{"k":1}}"""
|
||||
val cleared = ConcordJson.decodeOrNull<MetadataEntity>(head)!!.copy(description = null)
|
||||
val out = parse(ConcordJson.encodePreserving(MetadataEntity.serializer(), cleared, head))
|
||||
assertFalse("description" in out)
|
||||
assertEquals(parse("""{"k":1}"""), out["custom"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelEditKeepsLegacyVoiceFlagAndCustom() {
|
||||
val head = """{"name":"lounge","private":false,"voice":true,"custom":{"topic":"x"}}"""
|
||||
val renamed = ConcordJson.decodeOrNull<ChannelEntity>(head)!!.copy(name = "hangout")
|
||||
val out = parse(ConcordJson.encodePreserving(ChannelEntity.serializer(), renamed, head))
|
||||
assertEquals(JsonPrimitive(true), out["voice"])
|
||||
assertEquals(parse("""{"topic":"x"}"""), out["custom"])
|
||||
assertEquals(JsonPrimitive("hangout"), out["name"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genesisHasNothingToPreserve() {
|
||||
val out = parse(ConcordJson.encodePreserving(ChannelEntity.serializer(), ChannelEntity(name = "general"), null))
|
||||
assertEquals(JsonPrimitive("general"), out["name"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageExpirationParsesPerCord08() {
|
||||
fun secs(json: String) = ConcordJson.decodeOrNull<MetadataEntity>(json)!!.messageExpirationSecs()
|
||||
assertEquals(2592000L, secs("""{"name":"a","message_expiration":2592000}"""))
|
||||
assertEquals(86400L, secs("""{"name":"a","message_expiration":86400.9}"""))
|
||||
assertNull(secs("""{"name":"a"}"""))
|
||||
assertNull(secs("""{"name":"a","message_expiration":0}"""))
|
||||
assertNull(secs("""{"name":"a","message_expiration":-5}"""))
|
||||
assertNull(secs("""{"name":"a","message_expiration":"2592000"}"""))
|
||||
assertNull(secs("""{"name":"a","message_expiration":{"v":1}}"""))
|
||||
assertNull(secs("""{"name":"a","message_expiration":null}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingAndClearingTheTimer() {
|
||||
val on = MetadataEntity(name = "a").withMessageExpiration(604800)
|
||||
assertEquals(604800L, on.messageExpirationSecs())
|
||||
val off = on.withMessageExpiration(null)
|
||||
assertNull(off.messageExpirationSecs())
|
||||
assertFalse("message_expiration" in parse(ConcordJson.encodePreserving(MetadataEntity.serializer(), off, null)))
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
|
||||
/**
|
||||
* Shared scaffolding for tests that hand-build Control Plane editions.
|
||||
*
|
||||
* The fold pins every derived coordinate to the `community_id` (a Grant at its member's
|
||||
* `grant_locator`, the Banlist at `banlist_locator`, Metadata at the id itself) and honors a
|
||||
* non-owner edition only when it cites its author's Grant (`vac`, CORD-04 §5). Tests that are
|
||||
* about something else build editions at [grantEid] / [banlistEid] / [COMMUNITY_ID_HEX] and run
|
||||
* them through [cited], which stamps every uncited non-owner edition with the citation an honest
|
||||
* client would have written — its author's Grant head as the fold sees it.
|
||||
*/
|
||||
object ControlFixtures {
|
||||
const val COMMUNITY_ID_HEX = "c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedf"
|
||||
val communityId: ByteArray = COMMUNITY_ID_HEX.hexToByteArray()
|
||||
|
||||
/** `grant_locator(community_id, member)` as hex. */
|
||||
fun grantEid(
|
||||
member: String,
|
||||
cid: ByteArray = communityId,
|
||||
): String = ConcordKeyDerivation.grantCoordinate(cid, member.hexToByteArray()).toHexKey()
|
||||
|
||||
/** `banlist_locator(community_id)` as hex. */
|
||||
fun banlistEid(cid: ByteArray = communityId): String = ConcordKeyDerivation.banlistCoordinate(cid).toHexKey()
|
||||
|
||||
/**
|
||||
* [editions] with every non-owner edition that carries no `vac` given the citation its author
|
||||
* would have written: their Grant head in the resolved roster. Iterated because a delegated
|
||||
* granter's own Grant only resolves once the editions above it are cited. Editions that already
|
||||
* carry a citation (a test's deliberate forgery, say) are left exactly as they are.
|
||||
*/
|
||||
fun cited(
|
||||
editions: List<ControlEdition>,
|
||||
owner: String,
|
||||
cid: ByteArray = communityId,
|
||||
): List<ControlEdition> {
|
||||
var current = editions
|
||||
repeat(editions.size + 1) {
|
||||
val authority = AuthorityResolver.resolve(current, cid, owner)
|
||||
var changed = false
|
||||
val next =
|
||||
current.map { e ->
|
||||
if (e.authorityCitation != null || e.author.equals(owner, ignoreCase = true)) {
|
||||
e
|
||||
} else {
|
||||
authority.citationFor(e.author)?.let {
|
||||
changed = true
|
||||
e.withCitation(it)
|
||||
} ?: e
|
||||
}
|
||||
}
|
||||
if (!changed) return current
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
fun resolve(
|
||||
editions: List<ControlEdition>,
|
||||
owner: String,
|
||||
cid: ByteArray = communityId,
|
||||
): AuthorityResolver = AuthorityResolver.resolve(cited(editions, owner, cid), cid, owner)
|
||||
|
||||
fun fold(
|
||||
editions: List<ControlEdition>,
|
||||
owner: String,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
cid: ByteArray = communityId,
|
||||
): ConcordCommunityState = ConcordCommunityState.fold(cited(editions, owner, cid), cid, owner, floors)
|
||||
|
||||
fun authorizedHeads(
|
||||
editions: List<ControlEdition>,
|
||||
owner: String,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
cid: ByteArray = communityId,
|
||||
): Map<String, EntityFloor> = ConcordCommunityState.authorizedHeads(cited(editions, owner, cid), cid, owner, floors)
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* 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.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.BAN
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* CORD-04 §1/§2/§5 fold rules the reference client (Armada `control.ts`) enforces and this client
|
||||
* did not: Grant coordinates bound to their member (S5), authority-first equal-version ties (S7),
|
||||
* the `vac` authority citation (I4), `role_id` in Role content (I5) and the Role caps (I17).
|
||||
*/
|
||||
class ControlPlaneConformanceTest {
|
||||
private val owner = "0f".repeat(32)
|
||||
private val alice = "a1".repeat(32)
|
||||
private val bob = "b2".repeat(32)
|
||||
private val carol = "c3".repeat(32)
|
||||
|
||||
private val adminRole = "11".repeat(32)
|
||||
private val modRole = "22".repeat(32)
|
||||
|
||||
private val cid = ControlFixtures.communityId
|
||||
|
||||
private fun ed(
|
||||
kind: ControlEntityKind,
|
||||
eid: String,
|
||||
version: Long,
|
||||
prev: ControlEdition?,
|
||||
content: String,
|
||||
author: String,
|
||||
rumorId: String,
|
||||
vac: AuthorityCitation? = null,
|
||||
) = ControlEdition(kind, eid.hexToByteArray(), version, prev?.hash, vac, content, author, rumorId, version)
|
||||
|
||||
private fun roleJson(
|
||||
name: String,
|
||||
position: Int,
|
||||
permissions: String,
|
||||
roleId: String? = null,
|
||||
) = if (roleId == null) {
|
||||
"""{"name":"$name","position":$position,"permissions":"$permissions"}"""
|
||||
} else {
|
||||
"""{"role_id":"$roleId","name":"$name","position":$position,"permissions":"$permissions"}"""
|
||||
}
|
||||
|
||||
private fun grantJson(
|
||||
member: String,
|
||||
roleIds: List<String>,
|
||||
) = """{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}"""
|
||||
|
||||
// Admin: position 1, MANAGE_ROLES|KICK|BAN = 25. Mod: position 5, KICK|BAN = 24.
|
||||
private val adminDef = ed(ControlEntityKind.ROLE, adminRole, 1, null, roleJson("Admin", 1, "25"), owner, "role-admin")
|
||||
private val modDef = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24"), owner, "role-mod")
|
||||
private val aliceIsAdmin = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(adminRole)), owner, "grant-alice")
|
||||
|
||||
private fun banlist(
|
||||
version: Long,
|
||||
prev: ControlEdition?,
|
||||
author: String,
|
||||
rumorId: String,
|
||||
vararg banned: String,
|
||||
vac: AuthorityCitation? = null,
|
||||
) = ed(ControlEntityKind.BANLIST, ControlFixtures.banlistEid(), version, prev, "[${banned.joinToString(",") { "\"$it\"" }}]", author, rumorId, vac)
|
||||
|
||||
private fun citation(
|
||||
grant: ControlEdition,
|
||||
version: Long = grant.version,
|
||||
hash: ByteArray = grant.hash,
|
||||
) = AuthorityCitation(grant.entityId, version, hash)
|
||||
|
||||
// ---- S5: a Grant only counts at its member's own coordinate --------------------------------
|
||||
|
||||
@Test
|
||||
fun aGrantChainAtAForeignCoordinateCannotOverrideTheMembersOwn() {
|
||||
// grant_locator(community_id, member) is THE Grant coordinate (CORD-04 §1). A second chain for
|
||||
// the same member anywhere else used to be grouped as its own entity, and whichever chain the
|
||||
// map iterated last set the member's roles — so a MANAGE_ROLES holder could override the
|
||||
// owner's revoke, or the owner's grant, by arrival order.
|
||||
val foreignRevoke = ed(ControlEntityKind.GRANT, "99".repeat(32), 7, null, grantJson(alice, emptyList()), owner, "foreign")
|
||||
for (order in listOf(listOf(adminDef, aliceIsAdmin, foreignRevoke), listOf(foreignRevoke, adminDef, aliceIsAdmin))) {
|
||||
val r = AuthorityResolver.resolve(order, cid, owner)
|
||||
assertEquals(setOf(adminRole), r.rolesOf(alice), "a Grant at a foreign coordinate is not alice's Grant")
|
||||
}
|
||||
|
||||
// And a foreign-coordinate grant alone confers nothing, however well signed.
|
||||
val foreignGrant = ed(ControlEntityKind.GRANT, "98".repeat(32), 1, null, grantJson(bob, listOf(adminRole)), owner, "foreign-bob")
|
||||
assertNull(AuthorityResolver.resolve(listOf(adminDef, foreignGrant), cid, owner).rank(bob))
|
||||
}
|
||||
|
||||
// ---- S7: equal-version ties go to authority first ------------------------------------------
|
||||
|
||||
@Test
|
||||
fun anEqualVersionRoleForkGoesToTheOwnerOverALowerRumorId() {
|
||||
// Alice (Admin, MANAGE_ROLES at position 1) and the owner both edit the Mod role at v2. Alice's
|
||||
// rumor id sorts first — rumor ids are grindable — but authority decides (CORD-04 §1).
|
||||
val aliceV2 = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("Alice's", 5, "8"), alice, "0000")
|
||||
val ownerV2 = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("Owner's", 5, "8"), owner, "ffff")
|
||||
val r = ControlFixtures.resolve(listOf(adminDef, modDef, aliceIsAdmin, aliceV2, ownerV2), owner)
|
||||
assertEquals("Owner's", r.roles()[modRole]?.name)
|
||||
|
||||
// Among peers of equal rank the lower rumor id still settles it.
|
||||
val ownerV2b = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("Owner's other", 5, "8"), owner, "0001")
|
||||
assertEquals("Owner's other", ControlFixtures.resolve(listOf(adminDef, modDef, aliceIsAdmin, ownerV2, ownerV2b), owner).roles()[modRole]?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEqualVersionGrantForkGoesToTheHigherRankedGranter() {
|
||||
// Bob's Grant at v2: alice (rank 1) makes him Mod, the owner (rank 0) revokes him, same version.
|
||||
val bobV1 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, emptyList()), owner, "bob-1")
|
||||
val aliceV2 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 2, bobV1, grantJson(bob, listOf(modRole)), alice, "0000")
|
||||
val ownerV2 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 2, bobV1, grantJson(bob, emptyList()), owner, "ffff")
|
||||
val r = ControlFixtures.resolve(listOf(adminDef, modDef, aliceIsAdmin, bobV1, aliceV2, ownerV2), owner)
|
||||
assertNull(r.rank(bob), "the owner's revoke wins the tie")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickHeadBreaksAnEqualVersionTieOnRankBeforeRumorId() {
|
||||
val low = ed(ControlEntityKind.CHANNEL, "cc".repeat(32), 3, null, "{}", alice, "0000")
|
||||
val high = ed(ControlEntityKind.CHANNEL, "cc".repeat(32), 3, null, "{}", owner, "ffff")
|
||||
val older = ed(ControlEntityKind.CHANNEL, "cc".repeat(32), 2, null, "{}", owner, "0001")
|
||||
val rank: AuthorRank = { if (it == owner) 0L else 1L }
|
||||
|
||||
assertEquals(high, EditionFold.pickHead(listOf(low, high, older), rank) { true })
|
||||
assertEquals(low, EditionFold.pickHead(listOf(low, high, older), null) { true }, "without a rank: first passing candidate")
|
||||
assertEquals(low, EditionFold.pickHead(listOf(low, high, older), rank) { it !== high }, "a gated-out sibling never wins")
|
||||
assertEquals(older, EditionFold.pickHead(listOf(low, high, older), rank) { it === older }, "a lower version only when nothing above passes")
|
||||
}
|
||||
|
||||
// ---- I4: the vac authority citation --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aNonOwnerEditionWithoutACitationIsDropped() {
|
||||
// Alice holds BAN, but her banlist edition does not cite the Grant she acts under.
|
||||
val uncited = banlist(1, null, alice, "b1", carol)
|
||||
assertFalse(AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, uncited), cid, owner).isBanned(carol))
|
||||
|
||||
// The identical edition citing her Grant head is honored.
|
||||
val cited = banlist(1, null, alice, "b1", carol, vac = citation(aliceIsAdmin))
|
||||
assertTrue(AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, cited), cid, owner).isBanned(carol))
|
||||
|
||||
// The owner cites nothing.
|
||||
assertTrue(AuthorityResolver.resolve(listOf(banlist(1, null, owner, "b1", carol)), cid, owner).isBanned(carol))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aChannelEditionWithoutACitationIsDroppedToo() {
|
||||
// Channels fold under their own gate (the name rule), which must still require the vac.
|
||||
val channelsRole = "33".repeat(32)
|
||||
val channelsDef = ed(ControlEntityKind.ROLE, channelsRole, 1, null, roleJson("Chans", 3, ConcordPermissions.of(ConcordPermissions.MANAGE_CHANNELS).toWire()), owner, "role-chans")
|
||||
val aliceChans = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(channelsRole)), owner, "grant-alice-chans")
|
||||
val channelId = "ce".repeat(32)
|
||||
|
||||
fun channel(vac: AuthorityCitation?) = ed(ControlEntityKind.CHANNEL, channelId, 1, null, """{"name":"news"}""", alice, "chan-1", vac)
|
||||
|
||||
val uncited = ConcordCommunityState.fold(listOf(channelsDef, aliceChans, channel(null)), cid, owner)
|
||||
assertTrue(uncited.channels.isEmpty(), "an uncited channel edition is not honored")
|
||||
val cited = ConcordCommunityState.fold(listOf(channelsDef, aliceChans, channel(citation(aliceChans))), cid, owner)
|
||||
assertEquals("news", cited.channels[channelId]?.definition?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCitationMustNameTheActorsOwnGrantAtAVersionAndHashTheVerifierHolds() {
|
||||
val bobIsMod = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, listOf(modRole)), owner, "grant-bob")
|
||||
val base = listOf(adminDef, modDef, aliceIsAdmin, bobIsMod)
|
||||
|
||||
fun bansCarolCiting(vac: AuthorityCitation) = AuthorityResolver.resolve(base + banlist(1, null, alice, "b1", carol, vac = vac), cid, owner).isBanned(carol)
|
||||
|
||||
assertTrue(bansCarolCiting(citation(aliceIsAdmin)))
|
||||
assertFalse(bansCarolCiting(citation(bobIsMod)), "someone else's Grant is not the actor's authority")
|
||||
assertFalse(bansCarolCiting(citation(aliceIsAdmin, version = 2)), "a version the verifier has not synced parks the action")
|
||||
assertFalse(bansCarolCiting(citation(aliceIsAdmin, hash = ByteArray(32) { 7 })), "a forked or forged hash parks it too")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCitationOfASupersededGrantPassesTheSyncFloorButRankStillDecides() {
|
||||
// The head moved on (compaction discards superseded versions), so an older citation is
|
||||
// satisfied — the verdict is the CURRENT roster's. Promoted: honored. Demoted: dropped.
|
||||
val aliceV2 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 2, aliceIsAdmin, grantJson(alice, listOf(adminRole)) + " ", owner, "grant-alice-2")
|
||||
val ban = banlist(1, null, alice, "b1", carol, vac = citation(aliceIsAdmin))
|
||||
assertTrue(AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, aliceV2, ban), cid, owner).isBanned(carol))
|
||||
|
||||
val demoted = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 2, aliceIsAdmin, grantJson(alice, emptyList()), owner, "grant-alice-revoke")
|
||||
assertFalse(
|
||||
AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, demoted, ban), cid, owner).isBanned(carol),
|
||||
"citing the old valid Grant grandfathers nothing",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delegatedGrantsNeedTheGranterToCiteTheirOwnGrant() {
|
||||
// Alice (Admin) makes bob a Mod. Without her citation the grant does not stand.
|
||||
val bobByAlice = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, listOf(modRole)), alice, "grant-bob")
|
||||
assertNull(AuthorityResolver.resolve(listOf(adminDef, modDef, aliceIsAdmin, bobByAlice), cid, owner).rank(bob))
|
||||
val cited = bobByAlice.withCitation(citation(aliceIsAdmin))
|
||||
assertEquals(5L, AuthorityResolver.resolve(listOf(adminDef, modDef, aliceIsAdmin, cited), cid, owner).rank(bob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorityCitationsCiteTheActorsFoldedGrantHead() {
|
||||
val r = AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin), cid, owner)
|
||||
|
||||
assertNull(AuthorityCitations.forActor(r, owner), "the owner cites nothing")
|
||||
assertNull(AuthorityCitations.forActor(r, carol), "nor does someone holding no Grant")
|
||||
|
||||
val vac = AuthorityCitations.forActor(r, alice)
|
||||
assertNotNull(vac)
|
||||
assertEquals(ControlFixtures.grantEid(alice), vac.grantId.toHexKey())
|
||||
assertEquals(aliceIsAdmin.version, vac.grantVersion)
|
||||
assertContentEquals(aliceIsAdmin.hash, vac.grantHash)
|
||||
assertTrue(AuthorityCitations.isSatisfied(r, alice, vac))
|
||||
|
||||
// The editions overload folds the same roster.
|
||||
assertContentEquals(vac.grantHash, AuthorityCitations.forActor(listOf(adminDef, aliceIsAdmin), cid, owner, alice)?.grantHash)
|
||||
}
|
||||
|
||||
// ---- I5: role_id ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aRoleWhoseRoleIdNamesAnotherCoordinateIsRefused() {
|
||||
val grant = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(modRole)), owner, "g")
|
||||
|
||||
val matching = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24", roleId = modRole), owner, "r")
|
||||
assertEquals(5L, AuthorityResolver.resolve(listOf(matching, grant), cid, owner).rank(alice))
|
||||
|
||||
val legacy = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24"), owner, "r")
|
||||
assertEquals(5L, AuthorityResolver.resolve(listOf(legacy, grant), cid, owner).rank(alice), "a legacy role without role_id still reads")
|
||||
|
||||
val mismatched = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24", roleId = adminRole), owner, "r")
|
||||
assertNull(AuthorityResolver.resolve(listOf(mismatched, grant), cid, owner).rank(alice), "role_id must equal the eid")
|
||||
}
|
||||
|
||||
// ---- I17: caps -----------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aRoleNamePastSixtyFourBytesFallsBackToThePreviousEdition() {
|
||||
val renamed = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("m".repeat(65), 5, "24"), owner, "r2")
|
||||
assertEquals("Mod", ControlFixtures.resolve(listOf(modDef, renamed), owner).roles()[modRole]?.name)
|
||||
val atCap = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("m".repeat(64), 5, "24"), owner, "r2")
|
||||
assertEquals("m".repeat(64), ControlFixtures.resolve(listOf(modDef, atCap), owner).roles()[modRole]?.name)
|
||||
}
|
||||
|
||||
private fun roleIdOf(i: Int) = i.toString(16).padStart(64, '0')
|
||||
|
||||
@Test
|
||||
fun aMemberHoldsAtMostSixtyFourRoles() {
|
||||
val roles = (1..70).map { ed(ControlEntityKind.ROLE, roleIdOf(it), 1, null, roleJson("R$it", 10 + it, "8"), owner, "r$it") }
|
||||
val everything = (1..70).map { roleIdOf(it) }
|
||||
val grant = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, everything), owner, "g")
|
||||
|
||||
val held = AuthorityResolver.resolve(roles + grant, cid, owner).rolesOf(alice)
|
||||
assertEquals(everything.take(64).toSet(), held, "the first 64 role_ids, the rest ignored")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCommunityFoldsAtMostOneHundredRolesTheLowestRoleIds() {
|
||||
val roles = (1..101).map { ed(ControlEntityKind.ROLE, roleIdOf(it), 1, null, roleJson("R$it", 10, "16"), owner, "r$it") }
|
||||
val lowest = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(roleIdOf(1))), owner, "ga")
|
||||
val highest = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, listOf(roleIdOf(101))), owner, "gb")
|
||||
|
||||
val r = AuthorityResolver.resolve(roles.reversed() + lowest + highest, cid, owner)
|
||||
assertEquals(100, r.roles().size)
|
||||
assertTrue(roleIdOf(101) !in r.roles(), "the highest role_id is the one ignored")
|
||||
assertTrue(r.hasPermission(alice, BAN))
|
||||
assertFalse(r.hasPermission(bob, BAN), "a grant of an ignored role confers nothing")
|
||||
}
|
||||
}
|
||||
+58
-32
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -60,7 +59,7 @@ import kotlin.test.assertTrue
|
||||
* it now picks the chain head instead.
|
||||
*
|
||||
* 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
|
||||
* its own floor-less chain walk, where the chain-verified head outranks a dangling version, so an
|
||||
* 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.
|
||||
@@ -70,9 +69,9 @@ class ControlPlaneVersionExhaustionTest {
|
||||
private val bob = "b2".repeat(32)
|
||||
|
||||
private val modRole = "22".repeat(32)
|
||||
private val metadataEntity = "66".repeat(32)
|
||||
private val metadataEntity = ControlFixtures.COMMUNITY_ID_HEX
|
||||
private val channelEntity = "55".repeat(32)
|
||||
private val banlistEntity = "44".repeat(32)
|
||||
private val banlistEntity = ControlFixtures.banlistEid()
|
||||
|
||||
private fun edition(
|
||||
kind: ControlEntityKind,
|
||||
@@ -90,7 +89,7 @@ class ControlPlaneVersionExhaustionTest {
|
||||
vararg rest: ControlEdition,
|
||||
) = listOf(
|
||||
edition(ControlEntityKind.ROLE, modRole, 0, null, """{"name":"Mod","position":5,"permissions":"$permissions"}""", owner, "role-mod"),
|
||||
edition(ControlEntityKind.GRANT, "32".repeat(32), 0, null, """{"member":"$bob","role_ids":["$modRole"]}""", owner, "grant-bob"),
|
||||
edition(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 0, null, """{"member":"$bob","role_ids":["$modRole"]}""", owner, "grant-bob"),
|
||||
) + rest
|
||||
|
||||
@Test
|
||||
@@ -99,11 +98,11 @@ class ControlPlaneVersionExhaustionTest {
|
||||
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0)
|
||||
|
||||
// A client that has folded this community once holds a floor for the metadata entity.
|
||||
val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner)
|
||||
val floorsBefore = ControlFixtures.authorizedHeads(community, owner)
|
||||
assertEquals(0, floorsBefore[metadataEntity]?.version, "an ordinary floor at the genesis edition")
|
||||
|
||||
val poison = edition(ControlEntityKind.METADATA, metadataEntity, Long.MAX_VALUE, metadataV0.hash, """{"name":"PWNED"}""", bob, "meta-poison")
|
||||
val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore)
|
||||
val floorsAfter = ControlFixtures.authorizedHeads(community + poison, owner, floorsBefore)
|
||||
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.
|
||||
@@ -112,39 +111,66 @@ class ControlPlaneVersionExhaustionTest {
|
||||
|
||||
assertEquals(
|
||||
"My Community",
|
||||
ConcordCommunityState.fold(pool, owner).metadata?.name,
|
||||
ControlFixtures.fold(pool, owner).metadata?.name,
|
||||
"a fresh joiner walks the chain and is unaffected",
|
||||
)
|
||||
assertEquals(
|
||||
"My Community",
|
||||
ConcordCommunityState.fold(pool, owner, floorsAfter).metadata?.name,
|
||||
ControlFixtures.fold(pool, owner, floorsAfter).metadata?.name,
|
||||
"a client holding a floor follows the honest chain, not the stray",
|
||||
)
|
||||
assertEquals(
|
||||
"My Community",
|
||||
ConcordCommunityState.fold(community + repair, owner, floorsAfter).metadata?.name,
|
||||
ControlFixtures.fold(community + repair, owner, floorsAfter).metadata?.name,
|
||||
"and a Refounding that drops the poison stays repaired",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The poison here renames rather than deletes: CORD-03 §2 makes a Channel deletion by any
|
||||
* authorized holder terminal across the whole accepted edition set (the reference client's
|
||||
* `everDeleted`), whatever its version, so a max-version *delete* from bob now retires the
|
||||
* Channel by design. What must still hold is that a max-version edition cannot pin the Channel
|
||||
* to bob's content.
|
||||
*/
|
||||
@Test
|
||||
fun oneEditionAtMaxVersionNoLongerDeletesAChannel() {
|
||||
fun oneEditionAtMaxVersionNoLongerPinsAChannel() {
|
||||
val channelV0 = edition(ControlEntityKind.CHANNEL, channelEntity, 0, null, """{"name":"general"}""", owner, "chan-0")
|
||||
val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_CHANNELS).toWire(), channelV0)
|
||||
|
||||
val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner)
|
||||
val poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"general","deleted":true}""", bob, "chan-poison")
|
||||
val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore)
|
||||
val floorsBefore = ControlFixtures.authorizedHeads(community, owner)
|
||||
val poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"PWNED"}""", bob, "chan-poison")
|
||||
val floorsAfter = ControlFixtures.authorizedHeads(community + poison, owner, floorsBefore)
|
||||
|
||||
val repair = edition(ControlEntityKind.CHANNEL, channelEntity, 1, channelV0.hash, """{"name":"general"}""", owner, "chan-1")
|
||||
val pool = community + poison + repair
|
||||
|
||||
assertEquals(1, ConcordCommunityState.fold(pool, owner).channels.size, "a fresh joiner still sees the channel")
|
||||
assertEquals(1, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "and so does a client holding a floor")
|
||||
assertEquals(
|
||||
1,
|
||||
ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size,
|
||||
"the channel survives a Refounding too",
|
||||
"general",
|
||||
ControlFixtures
|
||||
.fold(pool, owner)
|
||||
.channels[channelEntity]
|
||||
?.definition
|
||||
?.name,
|
||||
"a fresh joiner follows the honest chain",
|
||||
)
|
||||
assertEquals(
|
||||
"general",
|
||||
ControlFixtures
|
||||
.fold(pool, owner, floorsAfter)
|
||||
.channels[channelEntity]
|
||||
?.definition
|
||||
?.name,
|
||||
"and so does a client holding a floor",
|
||||
)
|
||||
assertEquals(
|
||||
"general",
|
||||
ControlFixtures
|
||||
.fold(community + repair, owner, floorsAfter)
|
||||
.channels[channelEntity]
|
||||
?.definition
|
||||
?.name,
|
||||
"the channel stays repaired across a Refounding too",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,25 +178,25 @@ class ControlPlaneVersionExhaustionTest {
|
||||
fun aPoisonedBanlistStillAcceptsTheOwnersBan() {
|
||||
// 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.
|
||||
// its own floor-less chain walk, where the chain-verified head (the owner's v1) outranks any
|
||||
// dangling higher version, so the owner's ban lands even while the banlist's floor sat at
|
||||
// Long.MAX_VALUE. (It used to union every authorized fork too; CORD-04 §4 folds to one head.)
|
||||
// The floor can no longer be poisoned, but keep this.
|
||||
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 floorsBefore = ControlFixtures.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)
|
||||
val floorsAfter = ControlFixtures.authorizedHeads(community + poison, owner, floorsBefore)
|
||||
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
|
||||
|
||||
assertTrue(ConcordCommunityState.fold(pool, owner).authority.isBanned(bob), "a fresh joiner honors the ban")
|
||||
assertTrue(ControlFixtures.fold(pool, owner).authority.isBanned(bob), "a fresh joiner honors the ban")
|
||||
assertTrue(
|
||||
ConcordCommunityState.fold(pool, owner, floorsAfter).authority.isBanned(bob),
|
||||
"the re-heal union must keep the banlist working even with a poisoned floor",
|
||||
ControlFixtures.fold(pool, owner, floorsAfter).authority.isBanned(bob),
|
||||
"the chain-verified head keeps the banlist working even with a poisoned floor",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,14 +208,14 @@ class ControlPlaneVersionExhaustionTest {
|
||||
// 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 floors = ControlFixtures.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,
|
||||
ControlFixtures.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + compacted, owner, floors).metadata?.name,
|
||||
"a compacted head whose prev dangles by design must still be adopted",
|
||||
)
|
||||
}
|
||||
@@ -200,7 +226,7 @@ class ControlPlaneVersionExhaustionTest {
|
||||
// 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 floors = ControlFixtures.authorizedHeads(community, owner)
|
||||
|
||||
val danglingPrev = ByteArray(32) { 0x7f }
|
||||
val tooFar =
|
||||
@@ -216,7 +242,7 @@ class ControlPlaneVersionExhaustionTest {
|
||||
|
||||
assertEquals(
|
||||
"My Community",
|
||||
ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + tooFar, owner, floors).metadata?.name,
|
||||
ControlFixtures.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + tooFar, owner, floors).metadata?.name,
|
||||
"a jump past the bound is a gap, not a head",
|
||||
)
|
||||
}
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* 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.pins
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
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 kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConcordPinsTest {
|
||||
private val alice = NostrSignerInternal(KeyPair())
|
||||
private val mallory = NostrSignerInternal(KeyPair())
|
||||
private val root = ByteArray(32) { 7 }
|
||||
private val channelA = "aa".repeat(32)
|
||||
private val channelB = "bb".repeat(32)
|
||||
private val plane = ConcordChannelKeys.publicChannel(root, channelA.hexToByteArray(), 3)
|
||||
|
||||
private suspend fun sendAndOpen(
|
||||
rumor: Event,
|
||||
signer: NostrSignerInternal = alice,
|
||||
encrypted: Boolean = true,
|
||||
) = ConcordStreamEnvelope.wrap(rumor, plane, signer, encrypted = encrypted).let { wrap -> wrap.id to ConcordStreamEnvelope.open(wrap, plane) }
|
||||
|
||||
@Test
|
||||
fun theCoordinateMatchesTheSpecDerivation() {
|
||||
// A.6: hkdf(ikm = community_id, salt = empty, info = "concord/pins" ‖ 0x00 ‖ channel_id), 32 bytes.
|
||||
// Expected value computed independently with Node's crypto.hkdfSync.
|
||||
assertEquals(
|
||||
"13ff5d549aa39c9f11993e2066c9122700a622553fddf22e68c107d3ea00d242",
|
||||
ConcordPinLists.coordinate("11".repeat(32), "22".repeat(32)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPinnedMessageVerifiesFromTheEntryAlone() =
|
||||
runTest {
|
||||
val rumor = ChannelChat.message(alice.pubKey, channelA, 3, "ship it", 1_700_000_000)
|
||||
val (wrapId, opened) = sendAndOpen(rumor)
|
||||
val entry = assertNotNull(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, wrapId))
|
||||
|
||||
// A reader holding nothing but the entry and the list's channel.
|
||||
val pin = assertNotNull(ConcordPins.verify(entry, channelA))
|
||||
assertEquals(rumor.id, pin.rumorId)
|
||||
assertEquals(alice.pubKey, pin.author)
|
||||
assertEquals("ship it", pin.content)
|
||||
assertEquals("3", pin.epoch)
|
||||
assertEquals(wrapId, pin.wrapHint)
|
||||
assertFalse(pin.edited)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theDisclosureIsOneMessagesKeysNotTheConversationKey() =
|
||||
runTest {
|
||||
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "hi", 1))
|
||||
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
|
||||
val keys = (entry["keys"] as JsonPrimitive).content
|
||||
assertEquals(152, keys.length)
|
||||
assertFalse(keys.contains(plane.conversationKey.toHexKey()))
|
||||
// The same keys do not open a second message on the same plane.
|
||||
val (_, other) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "other", 2))
|
||||
assertNull(PinKeyDisclosure.decryptWith(other.seal.content, PinKeyDisclosure.decode(keys)!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPinReplayedIntoAnotherChannelsListFails() =
|
||||
runTest {
|
||||
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "private words", 1))
|
||||
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
|
||||
assertNull(ConcordPins.verify(entry, channelB), "the channel binding is step 4")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tamperedKeysOrSealAreDroppedAlone() =
|
||||
runTest {
|
||||
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "x", 1))
|
||||
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
|
||||
val badKeys = JsonObject(entry + ("keys" to JsonPrimitive("00".repeat(76))))
|
||||
assertNull(ConcordPins.verify(badKeys, channelA))
|
||||
val seal = entry["seal"] as JsonObject
|
||||
val forgedSeal = JsonObject(seal + ("pubkey" to JsonPrimitive(mallory.pubKey)))
|
||||
assertNull(ConcordPins.verify(JsonObject(entry + ("seal" to forgedSeal)), channelA))
|
||||
assertNull(ConcordPins.verify(JsonObject(entry - "keys"), channelA))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyMessagesAndRepliesArePinnable() =
|
||||
runTest {
|
||||
val reaction = ChannelChat.reaction(alice.pubKey, channelA, 3, "cc".repeat(32), alice.pubKey, 9, "+", 1)
|
||||
val (_, opened) = sendAndOpen(reaction)
|
||||
assertNull(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPlaintextSealCannotBePinned() =
|
||||
runTest {
|
||||
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "x", 1), encrypted = false)
|
||||
assertNull(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEditByTheAuthorRevisesThePinAndAForeignEditIsIgnored() =
|
||||
runTest {
|
||||
val original = ChannelChat.message(alice.pubKey, channelA, 3, "teh plan", 1)
|
||||
val (_, opened) = sendAndOpen(original)
|
||||
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
|
||||
|
||||
val (_, edit) = sendAndOpen(ChannelChat.edit(alice.pubKey, channelA, 3, original.id, "the plan", 2))
|
||||
val edited = ConcordPins.withEdit(entry, edit, plane.conversationKey, channelA)
|
||||
val pin = ConcordPins.verify(edited, channelA)!!
|
||||
assertTrue(pin.edited)
|
||||
assertEquals("the plan", pin.content)
|
||||
assertEquals(original.id, pin.rumorId, "the identity stays the original's")
|
||||
|
||||
val (_, foreign) = sendAndOpen(ChannelChat.edit(mallory.pubKey, channelA, 3, original.id, "pwned", 3), signer = mallory)
|
||||
assertEquals(edited, ConcordPins.withEdit(edited, foreign, plane.conversationKey, channelA), "an unprovable edit never downgrades the entry")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publicAndSealedListsRoundTrip() =
|
||||
runTest {
|
||||
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "pinned", 1))
|
||||
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
|
||||
|
||||
val public = ConcordPins.read(ConcordPins.serializePublic(listOf(entry))) { null }
|
||||
assertEquals(listOf(entry), public.entries)
|
||||
|
||||
val sealed = ConcordPins.serializeSealed(listOf(entry), plane.conversationKey, 3)
|
||||
val opened3 = ConcordPins.read(sealed) { epoch -> plane.conversationKey.takeIf { epoch == 3L } }
|
||||
assertEquals(listOf(entry), opened3.entries)
|
||||
val noKey = ConcordPins.read(sealed) { null }
|
||||
assertTrue(noKey.sealedUnavailable, "unreadable is not empty: a writer must not build on it")
|
||||
assertTrue(noKey.entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun capsReadAsEmptyAndRefuseToWrite() =
|
||||
runTest {
|
||||
val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "p", 1))
|
||||
val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!
|
||||
val tooMany = List(ConcordPins.MAX_ENTRIES + 1) { entry }
|
||||
assertFailsWith<ConcordPins.PinListTooLargeException> { ConcordPins.serializePublic(tooMany) }
|
||||
val handMade = """{"entries":[${tooMany.joinToString(",") { it.toString() }}]}"""
|
||||
assertTrue(ConcordPins.read(handMade) { null }.violating)
|
||||
assertTrue(ConcordPins.read("x".repeat(ConcordPins.MAX_CONTENT_BYTES + 1)) { null }.violating)
|
||||
assertTrue(ConcordPins.read("not json") { null }.violating)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyTheAuthorsDeleteKillsAPin() =
|
||||
runTest {
|
||||
val rumor = ChannelChat.message(alice.pubKey, channelA, 3, "oops", 1)
|
||||
val (_, opened) = sendAndOpen(rumor)
|
||||
val pin = ConcordPins.verify(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!, channelA)!!
|
||||
val tags = arrayOf(arrayOf("e", rumor.id), arrayOf("k", "9"))
|
||||
assertTrue(ConcordPins.killedBy(pin, alice.pubKey, tags))
|
||||
assertFalse(ConcordPins.killedBy(pin, mallory.pubKey, tags))
|
||||
}
|
||||
}
|
||||
+102
-25
@@ -31,9 +31,12 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
@@ -55,21 +58,14 @@ class ConcordInviteClassifyTest {
|
||||
name = "Nostrichs",
|
||||
)
|
||||
|
||||
/** A raw kind-33301 event at the link-signer coordinate carrying an arbitrary [vsk] wire value. */
|
||||
/** A kind-33301 event at the coordinate of [linkSigner], signed by it, carrying an arbitrary [vsk] wire value. */
|
||||
private fun coordinateEvent(
|
||||
linkSignerPubKey: String,
|
||||
linkSigner: ByteArray,
|
||||
vsk: String,
|
||||
createdAt: Long,
|
||||
content: String = "",
|
||||
) = Event(
|
||||
id = "00".repeat(32),
|
||||
pubKey = linkSignerPubKey,
|
||||
createdAt = createdAt,
|
||||
kind = ConcordInviteBundleEvent.KIND,
|
||||
tags = arrayOf(arrayOf("d", ""), VskTag.TAG_NAME.let { arrayOf(it, vsk) }),
|
||||
content = content,
|
||||
sig = "00".repeat(64),
|
||||
)
|
||||
dTag: String = "",
|
||||
): Event = NostrSignerSync(KeyPair(privKey = linkSigner)).sign(createdAt, ConcordInviteBundleEvent.KIND, arrayOf(arrayOf("d", dTag), arrayOf(VskTag.TAG_NAME, vsk)), content)
|
||||
|
||||
@Test
|
||||
fun liveBundleOpens() =
|
||||
@@ -77,7 +73,7 @@ class ConcordInviteClassifyTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
|
||||
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token)
|
||||
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token)
|
||||
assertTrue(status is InviteBundleStatus.Live)
|
||||
assertEquals(community.communityIdHex, status.invite.communityId)
|
||||
}
|
||||
@@ -89,11 +85,11 @@ class ConcordInviteClassifyTest {
|
||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
|
||||
// A newer vsk=9 tombstone at the same coordinate buries the still-openable bundle.
|
||||
val tombstone = coordinateEvent(minted.linkSignerPubKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
|
||||
val tombstone = coordinateEvent(minted.linkSignerPrivKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
|
||||
|
||||
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.token))
|
||||
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.linkSignerPubKey, minted.token))
|
||||
// Order of the fetched list must not matter — newest createdAt wins regardless.
|
||||
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.token))
|
||||
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.linkSignerPubKey, minted.token))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -111,7 +107,7 @@ class ConcordInviteClassifyTest {
|
||||
|
||||
// Both fetch orders must resolve to the re-mint — `fetchAll` gives no ordering guarantee.
|
||||
listOf(listOf(minted.bundleEvent, remint), listOf(remint, minted.bundleEvent)).forEach { wraps ->
|
||||
val status = ConcordInviteBundle.classify(wraps, minted.token)
|
||||
val status = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token)
|
||||
assertTrue(status is InviteBundleStatus.Live)
|
||||
assertEquals("bb".repeat(32), status.invite.communityRoot)
|
||||
assertEquals(2L, status.invite.rootEpoch)
|
||||
@@ -123,8 +119,9 @@ class ConcordInviteClassifyTest {
|
||||
runTest {
|
||||
// A mis-posted registry (vsk=8) at the bundle coordinate — the exact shape of the
|
||||
// relayop.xyz link that hung — is present but not a vsk=6 bundle we can open.
|
||||
val registry = coordinateEvent("aa".repeat(32), ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable")
|
||||
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16)))
|
||||
val signer = KeyPair()
|
||||
val registry = coordinateEvent(signer.privKey!!, ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable")
|
||||
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), signer.pubKey.toHexKey(), ByteArray(16)))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,11 +149,11 @@ class ConcordInviteClassifyTest {
|
||||
val wraps = listOf(minted.bundleEvent)
|
||||
|
||||
// Before the expiry the very same bundle still opens…
|
||||
val live = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs - 1)
|
||||
val live = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token, nowMs = expiresAtMs - 1)
|
||||
assertTrue(live is InviteBundleStatus.Live)
|
||||
|
||||
// …and after it, the join path must refuse it (not Live) while the preview data survives.
|
||||
val expired = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs + 1)
|
||||
val expired = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token, nowMs = expiresAtMs + 1)
|
||||
assertTrue(expired is InviteBundleStatus.Expired)
|
||||
assertEquals(community.communityIdHex, expired.invite.communityId)
|
||||
}
|
||||
@@ -167,12 +164,12 @@ class ConcordInviteClassifyTest {
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live)
|
||||
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyFetchIsAbsent() {
|
||||
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16)))
|
||||
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), "aa".repeat(32), ByteArray(16)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -201,12 +198,12 @@ class ConcordInviteClassifyTest {
|
||||
|
||||
// End to end: what the creator publishes is what every redeemer then resolves.
|
||||
val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L)
|
||||
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.token))
|
||||
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.linkSignerPubKey, minted.token))
|
||||
|
||||
// And a re-mint that lands AFTER the grave un-revokes the link, which is exactly why the
|
||||
// refresh path must skip a coordinate it did not resolve Live first.
|
||||
val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, inviteFor(community), createdAt = 3L)
|
||||
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.token) is InviteBundleStatus.Live)
|
||||
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.linkSignerPubKey, minted.token) is InviteBundleStatus.Live)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -218,6 +215,86 @@ class ConcordInviteClassifyTest {
|
||||
"""{"content":"ApoDjyzcHUg2imEiqw6Gsfpc2O86r+CMtMor+jc8ZlgrYwlI6CCmX7qGGEQvEJ5537nINE9H09Ro8RtEghpYgwkhdPHS274RpklFmuyLMdcoC5u1EVhppu8BrlHZ0YBfw3GX1Ui0uwy3V/J+rvrYiLhdREmwlK39JAX8sZfzCUhVtDMCgLVy03dwdpTC1Kj/ZeZJTYhJ8qmaN2273jgBTno/bFLzJlYvbANss69Tg53mljcmdSyhMlZ8z1kuenm1zkrPO5yHvi//r25tXkXb580OCkWxTmEwFzo20ntMgFnVSwVRvLZelOZt++tMevqi2Z5asvDgG7RytHP/0vLxxPzmjH0No+nITsxcmDbEweoKvSSzoc/7DYzENmfmrLXgP2KU/eE6CpTcSNaedLVKbAu9XptdtV8ruZxHjVBh1wpOwXkETEdqqvbCiR4TCNWzqbmwRKJ+acvZLBxhXcpfqmRsolaATU4sZKLs4iu92YpMIuUDh2Pquu0Daiz/IGnVe7BPb7E/gSd9NBFIxds6Nk1DbP8XKMRtYmWdTforUPWZqdM4EOtt8AcNpALRmsbEF26Gyd6t4/81bQPh+7WhI97lR/KkdWtKxNjjJ4CoJLgceyHuwbxXnFR23IWhzvQpBY12MBeYOw9oizvEzEGhEqpUns6LkH2sUNRRXbneNNvVgCEk6BK7j6Dxi95mcGJDEtOW+coE1SjhnfrwjIsdJL7cUEyC5DHFKuvxUi0iw/1I6b3AfZV5+A1tssEE2dhDv8uw6B3/a5EfMURFDqSfmGw1btdPPJ3+yjo1yYu2BtbYa4U++GtaAJfmNPrsB9lm4YgXuwCCRSpI2+TR9H2ntWM2j3HVdXqOpg3kfX82o9KFndo2g+7vGrOAyfL1jcybluq7AxPEV6D5yBky82MjoMeS0vSM6ytYu+0jheWPwDVs/3iPTELHPeDXAZOaw76ISBvNsXcxHvFsSiZBguBr+ucZOUnazVRAYIsmm/WNcIJu+6tfbyupqFCo5wkus6lKN2RNYIH1SRIi163cdBDhTBOdZoI2WcDr+SSW2fHtZutk7fW5IkJvSuy5xlke+YW/u3uzvriAIRmVDtk/fKISKEnMj2G47JdGn6EiHf+2+XfUSuDiliJb62pPXWBupinbb9HEW0tuyPHYGACH0/GA/egr6KMgI6YSh+BWS8vniMRTkmouKCzL5Csvc+2txC9LrfodrMF2R3jFZ1nig0mYzTQ9HvhqA2Uc+YG06iZtRaU7KqH6fMZYzPbjrxVOliyXR2G6","created_at":1784122846,"id":"112701bc1541c10b92f5a105e2e1f1813e591936e20075ec6a53c8bb8d235d81","kind":33301,"pubkey":"7177ccb8e8786c152e4960765f03fbceb7419d36a26e693a6399319760e7fd30","sig":"80eb4b49d70d73d35c1026b9c06d0fab280787950b5df412ebe4cdd05fcacadb4d20419c4e732749ed2698186a321069b4329ebd93fe21d8594d7392bf6445e0","tags":[["d",""],["vsk","8"]]}"""
|
||||
val event = Event.fromJson(json)
|
||||
val token = "c0277c415fe2ecc901a22b2f23dca5bf".hexToByteArray()
|
||||
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), token))
|
||||
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), event.pubKey, token))
|
||||
}
|
||||
|
||||
// ---- S14: the relay filter is a hint, not a proof (CORD-05 §2) ----------------------
|
||||
|
||||
@Test
|
||||
fun aForgedNewerRevocationByAnotherKeyDoesNotRevoke() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
|
||||
|
||||
// A relay serves a newer vsk=9 "at the coordinate" — but signed by someone else.
|
||||
val forger = KeyPair()
|
||||
val forged = coordinateEvent(forger.privKey!!, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
|
||||
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, forged), minted.linkSignerPubKey, minted.token))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNewerRevocationClaimingTheSignerButBadlySignedDoesNotRevoke() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
|
||||
|
||||
// Right pubkey, garbage signature: exactly what a relay could fabricate without the secret.
|
||||
val forged =
|
||||
Event(
|
||||
id = "00".repeat(32),
|
||||
pubKey = minted.linkSignerPubKey,
|
||||
createdAt = 2L,
|
||||
kind = ConcordInviteBundleEvent.KIND,
|
||||
tags = arrayOf(arrayOf("d", ""), arrayOf(VskTag.TAG_NAME, ControlEntityKind.INVITE_REVOKED.wire)),
|
||||
content = "",
|
||||
sig = "00".repeat(64),
|
||||
)
|
||||
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, forged), minted.linkSignerPubKey, minted.token))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRevocationAtAnotherDTagIsNotThisCoordinate() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
|
||||
|
||||
// Genuinely signed by the link signer, but at a different `d` — a different coordinate.
|
||||
val elsewhere = coordinateEvent(minted.linkSignerPrivKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L, dTag = "x")
|
||||
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, elsewhere), minted.linkSignerPubKey, minted.token))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBundleFromTheWrongAuthorIsAbsent() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
|
||||
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(listOf(minted.bundleEvent), "aa".repeat(32), minted.token))
|
||||
}
|
||||
|
||||
// ---- S11: bundle bounds (CORD-05 §1) --------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aBundleNamingTooManyChannelsIsRefused() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
val huge = inviteFor(community).copy(channels = List(ConcordInviteBundle.MAX_BUNDLE_CHANNELS + 1) { InviteChannel(id = it.toString(), epoch = 0) })
|
||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", huge, createdAt = 1L)
|
||||
assertNull(ConcordInviteBundle.bound(huge))
|
||||
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token))
|
||||
|
||||
val atCap = inviteFor(community).copy(channels = List(ConcordInviteBundle.MAX_BUNDLE_CHANNELS) { InviteChannel(id = it.toString(), epoch = 0) })
|
||||
val ok = ConcordInviteBundle.mintLink("https://vector.chat", atCap, createdAt = 1L)
|
||||
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(ok.bundleEvent), ok.linkSignerPubKey, ok.token))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBundlesRelaysAreTruncatedToTheCommunityCap() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
val relays = List(40) { "wss://r$it.example" }
|
||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community).copy(relays = relays + relays), createdAt = 1L)
|
||||
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token)
|
||||
assertIs<InviteBundleStatus.Live>(status)
|
||||
assertEquals(relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), status.invite.relays)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ class ConcordInviteJoinFlowTest {
|
||||
)
|
||||
assertFalse(controlPlane.canWrite, "an invite must never hand a joiner the write key")
|
||||
val editions = community.genesisWraps.mapNotNull { ControlEdition.fromRumor(ConcordStreamEnvelope.open(it, controlPlane).rumor) }
|
||||
val state = ConcordCommunityState.fold(editions, invite.owner)
|
||||
val state = ConcordCommunityState.fold(editions, invite.communityId.hexToByteArray(), invite.owner)
|
||||
assertEquals("Nostrichs", state.metadata?.name)
|
||||
assertTrue(state.channels.isNotEmpty()) // #general is visible to the new member
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user