Merge branch 'claude/hopeful-brown-1suxdw' into worktree-agent-a61ba32a0b1aecb13

Brings in F7 (private channels). ConcordDirectInviteInbox.visible now also hides a
catch-up that acceptPlan would refuse against the held fold (non-staff sender, a
channel the fold doesn't know as Private, a dissolved community).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
Claude
2026-09-29 19:41:05 +00:00
26 changed files with 3002 additions and 77 deletions
@@ -44,6 +44,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -77,15 +78,23 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_name
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.concord_channel_access_role_label
import com.vitorpamplona.amethyst.commons.resources.concord_channel_create
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_confirm
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_message
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_title
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_private
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_private_message
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_public
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_public_message
import com.vitorpamplona.amethyst.commons.resources.concord_channel_name_label
import com.vitorpamplona.amethyst.commons.resources.concord_channel_no_messages
import com.vitorpamplona.amethyst.commons.resources.concord_channel_private_hint
import com.vitorpamplona.amethyst.commons.resources.concord_channel_private_toggle
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename_save
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rotate_key
import com.vitorpamplona.amethyst.commons.resources.concord_channels_empty
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_action
import com.vitorpamplona.amethyst.commons.resources.concord_edit_title
@@ -119,6 +128,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -237,11 +247,11 @@ fun ConcordChannelListScreen(
initialName = editor.initialName,
isCreate = editor.channelIdHex == null,
onDismiss = { channelEditor = null },
onConfirm = { newName ->
onConfirm = { newName, makePrivate, accessRoleName ->
channelEditor = null
scope.launch {
if (editor.channelIdHex == null) {
account.concord.createConcordChannel(communityId, newName)
account.concord.createConcordChannel(communityId, newName, makePrivate, accessRoleName)
} else {
account.concord.renameConcordChannel(communityId, editor.channelIdHex, newName)
}
@@ -250,6 +260,26 @@ fun ConcordChannelListScreen(
)
}
// Privatise / publicise a channel (CORD-03 §2): both are MANAGE_CHANNELS edits, and both say
// plainly what they can't do — a conversion protects the future only.
var channelToConvert by remember { mutableStateOf<ConcordChannelConversion?>(null) }
channelToConvert?.let { target ->
ConcordChannelConvertDialog(
target = target,
onDismiss = { channelToConvert = null },
onConfirm = { accessRoleName ->
channelToConvert = null
scope.launch {
if (target.toPrivate) {
account.concord.privatizeConcordChannel(communityId, target.channelIdHex, accessRoleName)
} else {
account.concord.publicizeConcordChannel(communityId, target.channelIdHex)
}
}
},
)
}
channelToDelete?.let { target ->
val id = target.channelIdHex ?: return@let
AlertDialog(
@@ -445,6 +475,8 @@ fun ConcordChannelListScreen(
.keys
.sorted()
}
// A rotation needs the current key (CORD-06): only a holder can rotate.
val holdsKey = def.private && session?.entry?.let { ConcordChannelKeyring.heldKey(it, entry.key) } != null
ConcordChannelListRow(
communityId = communityId,
channelKey = entry.key,
@@ -456,6 +488,9 @@ fun ConcordChannelListScreen(
onClick = { nav.nav(Route.Concord(communityId, entry.key)) },
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
onTogglePrivate = { channelToConvert = ConcordChannelConversion(entry.key, name, toPrivate = !def.private) },
isPrivate = def.private,
onRotateKey = if (holdsKey) ({ scope.launch { account.concord.rekeyConcordChannel(communityId, entry.key) } }) else null,
)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
@@ -483,6 +518,9 @@ private fun ConcordChannelListRow(
onClick: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
onTogglePrivate: () -> Unit,
isPrivate: Boolean,
onRotateKey: (() -> Unit)?,
) {
val account = accountViewModel.account
// getOrCreate (not getIfExists): a channel folded on the Control Plane may have no message note
@@ -546,6 +584,9 @@ private fun ConcordChannelListRow(
ConcordChannelRowMenu(
onRename = onRename,
onDelete = onDelete,
onTogglePrivate = onTogglePrivate,
isPrivate = isPrivate,
onRotateKey = onRotateKey,
)
}
}
@@ -674,11 +715,62 @@ private data class ConcordChannelEditor(
val initialName: String,
)
/** The per-channel-row overflow menu (rename / delete), shown only to channel managers. */
/** A pending privatise ([toPrivate]) or publicise of [channelIdHex]. */
private data class ConcordChannelConversion(
val channelIdHex: String,
val name: String,
val toPrivate: Boolean,
)
/** Confirms a Private/Public conversion; privatising also names the new access Role. */
@Composable
private fun ConcordChannelConvertDialog(
target: ConcordChannelConversion,
onDismiss: () -> Unit,
onConfirm: (String?) -> Unit,
) {
var roleName by remember { mutableStateOf(target.name) }
val action = if (target.toPrivate) Res.string.concord_channel_make_private else Res.string.concord_channel_make_public
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(action)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (target.toPrivate) {
Text(stringRes(Res.string.concord_channel_make_private_message, target.name, roleName.ifBlank { target.name }))
OutlinedTextField(
value = roleName,
onValueChange = { roleName = it },
singleLine = true,
label = { Text(stringRes(Res.string.concord_channel_access_role_label)) },
modifier = Modifier.fillMaxWidth(),
)
} else {
Text(stringRes(Res.string.concord_channel_make_public_message, target.name))
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(roleName.trim().ifBlank { null }) }) {
Text(stringRes(action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.cancel))
}
},
)
}
/** The per-channel-row overflow menu (rename / privacy / rotate / delete), shown only to channel managers. */
@Composable
private fun ConcordChannelRowMenu(
onRename: () -> Unit,
onDelete: () -> Unit,
onTogglePrivate: () -> Unit,
isPrivate: Boolean,
onRotateKey: (() -> Unit)?,
) {
var expanded by remember { mutableStateOf(false) }
Box {
@@ -697,6 +789,22 @@ private fun ConcordChannelRowMenu(
onRename()
},
)
DropdownMenuItem(
text = { Text(stringRes(if (isPrivate) Res.string.concord_channel_make_public else Res.string.concord_channel_make_private)) },
onClick = {
expanded = false
onTogglePrivate()
},
)
onRotateKey?.let { rotate ->
DropdownMenuItem(
text = { Text(stringRes(Res.string.concord_channel_rotate_key)) },
onClick = {
expanded = false
rotate()
},
)
}
DropdownMenuItem(
text = {
Text(
@@ -719,9 +827,11 @@ private fun ConcordChannelEditDialog(
initialName: String,
isCreate: Boolean,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
onConfirm: (name: String, makePrivate: Boolean, accessRoleName: String?) -> Unit,
) {
var name by remember { mutableStateOf(initialName) }
var makePrivate by remember { mutableStateOf(false) }
var roleName by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = {
@@ -736,18 +846,43 @@ private fun ConcordChannelEditDialog(
)
},
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
label = { Text(stringRes(Res.string.concord_channel_name_label)) },
modifier = Modifier.fillMaxWidth(),
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
label = { Text(stringRes(Res.string.concord_channel_name_label)) },
modifier = Modifier.fillMaxWidth(),
)
// A new channel may be Private (CORD-03): its own key, and an access Role — the
// Roles scoped to a channel ARE its access list (CORD-04 §2).
if (isCreate) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringRes(Res.string.concord_channel_private_toggle), modifier = Modifier.weight(1f))
Switch(checked = makePrivate, onCheckedChange = { makePrivate = it })
}
if (makePrivate) {
Text(
stringRes(Res.string.concord_channel_private_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = roleName,
onValueChange = { roleName = it },
singleLine = true,
placeholder = { Text(name.trim()) },
label = { Text(stringRes(Res.string.concord_channel_access_role_label)) },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
},
confirmButton = {
TextButton(
enabled = name.isNotBlank(),
onClick = { if (name.isNotBlank()) onConfirm(name.trim()) },
onClick = { if (name.isNotBlank()) onConfirm(name.trim(), makePrivate, roleName.trim().ifBlank { null }) },
) {
Text(
stringRes(
+7 -3
View File
@@ -677,6 +677,10 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List
| `amy concord list` | List joined Concord communities. |
| `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 channel create COMMUNITY NAME [--private [--role NAME]]` | Create a channel (MANAGE_CHANNELS). `--private` gives it its own independent key at channel epoch 0 (stored before anything publishes) plus a bit-less access Role scoped to it (CORD-04 §2, default name = the channel's); nobody holds that Role yet — `concord grant` it to let members read. |
| `amy concord channel privatize COMMUNITY CHANNEL [--role NAME]` | Convert a Public channel to Private (CORD-03 §2): a fresh key at the next channel epoch — floored at the highest channel rotation found on the wire, refused (`inconclusive`) past 32 — plus an access Role, then the flag. Protects the future only. |
| `amy concord channel publicize COMMUNITY CHANNEL` | Convert a Private channel back to Public (flag only); the held key stays so the private era keeps reading. |
| `amy concord channel rekey COMMUNITY CHANNEL` | Rotate a Private channel's key (CORD-06 §1-2) to exactly the members its Roles entitle today plus us: 72-byte scope-bound blobs at the channel-rekey address, `vac` on every chunk. Needs MANAGE_CHANNELS and outranking every cut role holder; the key is reserved in `concord.json` so a re-run re-delivers the same one. |
| `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), then publish this account's Invite Registry (`vsk 8`, CORD-05 §5) listing its live link signers — expired links pruned. Output adds `registry_published`, `public` and `live_invite_links`. |
@@ -686,12 +690,12 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List
| `amy concord decline WRAP-ID` | Discard a Direct Invite; its wrap id is remembered in `concord-invites.json` so it never resurfaces. |
| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, records it in your Invite List, then republishes your Invite Registry without it. When it was the community's last live link the output carries `privatized: true` / `refound_required: true`: the community is Private now, and `concord refound COMMUNITY --privatize` rotates its keys (CORD-05 §2). |
| `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 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). Then follows every held private channel's own rotations (`channel_rekeys`): a complete, honored rotation off the key we hold is adopted; one from a rotator who outranks us that leaves us out drops the key and records the cut, so no older key comes back. |
| `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…]` / `--privatize` | 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. `--privatize` removes nobody: it converts a Public community to Private (owed once its last live invite link is revoked). |
| `amy concord refound COMMUNITY --remove U[,U…]` / `--privatize` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after, then rotates every held private channel to its entitled kept set, sealed under the prior root (`channels_rotated`); reserves its keys so a re-run resumes with the same root; refused for a dissolved community. `--privatize` removes nobody: it converts a Public community to Private (owed once its last live invite link is revoked). |
| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04), plus the community's mode from the folded Invite Registries (CORD-05 §5): `public` (true while any live invite link exists), `live_invite_links`, and `invite_registries` (links per creator). |
| `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 grant COMMUNITY USER ROLE-ID` | Grant a role to a member. Private-channel keys follow the Grant (CORD-03/06): every channel it opens to a member is vended to them by Direct Invite carrying only those channels (`channel_keys_vended`); every channel it closes is rotated (`channels_rotated`, or `channels_not_rotated` with the reason). Only keys this account holds can move (`channels_not_held`). |
| `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. A ban reports `public` and `refound_required`: a Public ban is the Banlist alone, while a ban from a Private community owes a Refounding (`concord refound COMMUNITY --remove USER`, CORD-06 §3). |
| `amy concord pins COMMUNITY CHANNEL` | The channel's Pin List (CORD-04 §7), every entry verified from its proof bundle; entries the author deleted are listed under `deleted`, `edited`/`stale_edit` flag revisions, and `sealed_unavailable` means the list is sealed under a key this account never held (unreadable, not empty). Expired (CORD-08) pinned messages are hidden like deleted ones. |
| `amy concord pin COMMUNITY CHANNEL RUMOR_ID [--force]` / `unpin COMMUNITY CHANNEL RUMOR_ID` | Pin / unpin a message (PIN_MESSAGES or owner, plus the control write key). A disappearing message (one carrying a CORD-08 `expiration`) is refused with `expiring_message` unless `--force`: the pin would keep its words past the timer. Pinning reopens the message's wrap to prove it with its original seal; a private channel's list is sealed under its current key. Refused (`list_unavailable`, `too_many_pins`, `too_large`, …) rather than published when the list is unreadable or a cap would break. |
@@ -44,6 +44,7 @@ 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.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
@@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
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.ConcordInviteVend
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
@@ -79,6 +81,15 @@ object ConcordCommands {
| concord import fetch + decrypt this account's kind:33302
| community list (carries heldRoots, CORD-06)
| concord channels COMMUNITY list a community's channels
| concord channel create COMMUNITY NAME create a channel (MANAGE_CHANNELS); --private
| [--private [--role NAME]] gives it its own key at channel epoch 0 plus a
| bit-less access Role (default: the channel name);
| grant that Role to let members read it
| concord channel privatize COMMUNITY CHANNEL convert a Public channel to Private: a fresh key
| [--role NAME] at the next channel epoch + an access Role
| concord channel publicize COMMUNITY CHANNEL convert a Private channel back to Public (flag only)
| concord channel rekey COMMUNITY CHANNEL rotate a Private channel's key to exactly the
| members its Roles entitle today (CORD-06)
| concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id)
| concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50);
| [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane
@@ -96,7 +107,9 @@ object ConcordCommands {
| it in your invite list so it stays retired
| 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
| open our blob and adopt the new epoch; then
| follow each held private channel's rotations
| (adopt the new key, or drop it when cut)
| 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
@@ -106,7 +119,9 @@ object ConcordCommands {
| and public: true/false + live invite links
| from the folded registries (CORD-05 §5)
| 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
| concord grant COMMUNITY USER ROLE-ID grant a role to a member; the private channels it
| opens are vended by Direct Invite, the ones it
| closes are rotated (CORD-03/06)
| concord ban COMMUNITY USER ban a member
| concord pins COMMUNITY CHANNEL the channel's verified Pin List (CORD-04 §7)
| concord pin COMMUNITY CHANNEL RUMOR_ID pin a message (PIN_MESSAGES); proves it with
@@ -133,7 +148,7 @@ object ConcordCommands {
route(
"concord",
tail,
"concord <create|list|import|channels|send|read|invite|invites|accept|decline|revoke|join|recover|rekey|roles|role|grant|ban|unban|pins|pin|unpin|refound|dissolve|timer>",
"concord <create|list|import|channels|channel|send|read|invite|invites|accept|decline|revoke|join|recover|rekey|roles|role|grant|ban|unban|pins|pin|unpin|refound|dissolve|timer>",
help = USAGE,
routes =
mapOf(
@@ -141,6 +156,7 @@ object ConcordCommands {
"list" to { rest -> list(dataDir, rest) },
"import" to { rest -> import(dataDir, rest) },
"channels" to { rest -> ConcordChannelCommands.channels(dataDir, rest) },
"channel" to { rest -> ConcordPrivateChannelCommands.channel(dataDir, rest) },
"send" to { rest -> ConcordChannelCommands.send(dataDir, rest) },
"read" to { rest -> ConcordChannelCommands.read(dataDir, rest) },
"invite" to { rest -> invite(dataDir, rest) },
@@ -729,9 +745,12 @@ object ConcordCommands {
0
}
is DirectInviteAcceptPlan.CatchUp -> {
val held = heldSc!!
store.upsert(storedFrom(held, plan.entry))
val added = plan.entry.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } }
// Re-applied to the record as stored NOW (the fold above took a while), never the
// snapshot the plan was computed from.
val held = store.load().firstOrNull { it.communityId == heldSc!!.communityId } ?: heldSc!!
val adopted = ConcordInviteVend.adoptCatchUp(entryFor(held), opened.invite, plan.channelIds) ?: plan.entry
store.upsert(storedFrom(held, adopted))
val added = adopted.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } }
Output.emit(done(mapOf("joined" to true, "catch_up" to true, "channels" to added.map { mapOf("id" to it.channelId, "name" to it.name, "epoch" to it.epoch) })))
0
}
@@ -840,7 +859,9 @@ object ConcordCommands {
}
/** The quartz list entry a [StoredCommunity] describes — the shape every commons helper takes. */
fun entryFor(sc: StoredCommunity) =
fun entryFor(sc: StoredCommunity) = sc.channelCuts.entries.fold(entryShape(sc)) { entry, (id, epoch) -> ConcordChannelKeyring.withCut(entry, id, epoch) }
private fun entryShape(sc: StoredCommunity) =
ConcordCommunityListEntry(
id = sc.communityId,
owner = sc.owner,
@@ -870,6 +891,7 @@ object ConcordCommands {
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) },
channelCuts = ConcordChannelKeyring.cutsOf(entry),
)
/**
@@ -1048,7 +1070,15 @@ object ConcordCommands {
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))
// Then every held Private Channel's own rotations (CORD-06 §2), off the records as stored
// now — a base adoption above may have moved the root they are sealed under.
val channelResults = mutableListOf<Map<String, Any?>>()
for (id in targets.map { it.communityId }) {
val fresh = store.load().firstOrNull { it.communityId == id } ?: continue
if (fresh.privateChannels.isEmpty() || isDissolved(ctx, fresh)) continue
channelResults += ConcordPrivateChannelCommands.drainChannelRekeys(ctx, store, fresh)
}
Output.emit(mapOf("communities" to results, "channel_rekeys" to channelResults))
return 0
}
}
@@ -29,10 +29,13 @@ 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.ConcordPrivateChannels
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.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
@@ -41,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
@@ -204,7 +208,11 @@ object ConcordModCommands {
)
val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc))
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + RawEventSupport.ackFields(ack))
// Role-gated channel keys follow the Grant (CORD-03/06): vend what it opened, rotate what it closed.
val before = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
val after = editions + ConcordActions.controlEditions(listOf(wrap), cp)
val access = ConcordPrivateChannelCommands.reconcileAccess(ctx, ConcordStore(dataDir.concordFile), loaded.community, before, after)
Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + access + RawEventSupport.ackFields(ack))
return 0
}
}
@@ -544,11 +552,33 @@ object ConcordModCommands {
}
val compactionFailures = build.controlWraps.count { wrap -> ctx.publish(wrap, relays).values.none { it.accepted } }
// 4b. Rotate every held Private Channel (CORD-06 §3), each to its OWN entitled set among
// the kept members, sealed under the PRIOR root so a base-fork loser can still open
// it. One that no relay takes keeps its key and is reported: resumable, not atomic.
val afterBans = ConcordCommunityState.fold(chain, sc.communityId.hexToByteArray(), sc.owner)
val kept = recipients.mapTo(HashSet()) { it.lowercase() }
var withChannels = ConcordCommands.entryFor(loaded.community)
val channelsRotated = mutableListOf<String>()
val channelsNotRotated = mutableListOf<String>()
for (held in withChannels.privateChannels) {
val id = held.channelId.lowercase()
if (id !in afterBans.privateChannelIds || ConcordChannelKeyring.heldKey(withChannels, id) == null) continue
val keep = ConcordPrivateChannels.keepSet(afterBans.authority, id, me).filterTo(HashSet()) { it in kept || it == me.lowercase() }
val newKey = ConcordChannelRekey.mintKey()
val wraps = ConcordPrivateChannels.buildRotation(ctx.signer, priorRoot, held, newKey, keep, TimeUtils.now(), citation)
if (wraps.all { wrap -> ctx.publish(wrap, relays).values.any { it.accepted } }) {
withChannels = ConcordChannelKeyring.withRotatedKey(withChannels, id, newKey.toHexKey(), held.epoch + 1) ?: withChannels
channelsRotated += id
} else {
channelsNotRotated += id
}
}
// 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the
// epoch we are leaving for the anti-rollback floor — and drop the reservation.
val adopted =
ConcordReceive.withAdoptedRoot(
ConcordCommands.entryFor(loaded.community),
withChannels,
keys.newRoot,
build.newEpoch,
build.newControlKeys.address.hexToByteArray(),
@@ -606,6 +636,8 @@ object ConcordModCommands {
"rekey_wraps" to build.rekeyWraps.size,
"compaction_failures" to compactionFailures,
"invites_refreshed" to refreshed,
"channels_rotated" to channelsRotated,
"channels_not_rotated" to channelsNotRotated,
),
)
return 0
@@ -0,0 +1,409 @@
/*
* 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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
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.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
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.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* `amy concord channel create|privatize|publicize|rekey` — Private Channels (CORD-03 §1-2,
* CORD-06 §1-2). Thin assembly over [ConcordPrivateChannels] (commons); the decisions — the key
* epoch, the access Role, who a rotation keeps, whether a received rotation is honored — are all
* shared with Amethyst. Like every amy verb that holds secrets, keys live in the local store only
* (amy does not republish the Community List).
*/
object ConcordPrivateChannelCommands {
/** How many channel epochs `privatize` probes for earlier rotations (Armada MAX_PROBED_CHANNEL_EPOCH). */
private const val MAX_PROBED_CHANNEL_EPOCH = 32L
suspend fun channel(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"concord channel",
tail,
"concord channel <create|privatize|publicize|rekey>",
routes =
mapOf(
"create" to { rest -> create(dataDir, rest) },
"privatize" to { rest -> privatize(dataDir, rest) },
"publicize" to { rest -> publicize(dataDir, rest) },
"rekey" to { rest -> rekey(dataDir, rest) },
),
)
/** Publishes [wraps] in order to [sc]'s relays; the first one no relay takes stops the run. */
private suspend fun publishAll(
ctx: Context,
sc: StoredCommunity,
wraps: List<Event>,
): Int? {
val relays = ConcordCommands.relaysFor(ctx, sc)
for (wrap in wraps) {
val ack = ctx.publish(wrap, relays)
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
}
return null
}
private fun canManageChannels(
authority: AuthorityResolver,
me: HexKey,
): Boolean = authority.isOwner(me) || authority.hasPermission(me, ConcordPermissions.MANAGE_CHANNELS)
private fun forbidden(): Int = Output.error("forbidden", "this needs the Manage-channels permission (CORD-03 §2); readers would drop the edition")
/** Stores [sc] with the Private Channel [key] (refused when it would not move the channel forward). */
private fun storeKey(
store: ConcordStore,
sc: StoredCommunity,
key: PrivateChannelKey,
): Boolean {
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
val next = ConcordChannelKeyring.withChannelKey(ConcordCommands.entryFor(fresh), key) ?: return false
store.upsert(ConcordCommands.storedFrom(fresh, next))
return true
}
/** `concord channel create COMMUNITY NAME [--private [--role NAME]]`. */
private suspend fun create(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val name = args.positional(1, "name")
val private = args.bool("private")
val roleName = args.flag("role")
args.rejectUnknown()
if (!ChannelEntity(name = name.trim()).hasValidName()) return Output.error("bad_args", "a channel name must be 1..${ChannelEntity.NAME_MAX_BYTES} UTF-8 bytes").let { 2 }
if (roleName != null && !private) return Output.error("bad_args", "--role names a Private channel's access Role; add --private").let { 2 }
val store = ConcordStore(dataDir.concordFile)
val sc = store.find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val loaded = ConcordModCommands.load(ctx, sc, dataDir)
val (cp, editions) = loaded
ConcordModCommands.writeGuard(cp)?.let { return it }
val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
if (!canManageChannels(authority, ctx.signer.pubKey)) return forbidden()
if (!private) {
val channelId = RandomInstance.bytes(32)
val wrap = ConcordModeration.defineChannel(ctx.signer, cp, sc.communityId.hexToByteArray(), channelId, ChannelEntity(name = name.trim()), editions, TimeUtils.now(), owner = sc.owner)
publishAll(ctx, sc, listOf(wrap))?.let { return it }
Output.emit(mapOf("channel_id" to channelId.toHexKey(), "name" to name.trim(), "private" to false))
return 0
}
val build =
ConcordPrivateChannels.create(ctx.signer, cp, sc.communityId.hexToByteArray(), name, roleName, editions, authority, sc.owner, TimeUtils.now())
?: return Output.error("forbidden", "no rank to mint this channel's access Role from")
// The key goes into the store BEFORE the editions publish: otherwise a crash orphans the only copy.
if (!storeKey(store, loaded.community, build.key)) return Output.error("conflict", "could not store the new channel key")
publishAll(ctx, sc, build.wraps)?.let { return it }
Output.emit(
mapOf(
"channel_id" to build.channelIdHex,
"name" to name.trim(),
"private" to true,
"channel_epoch" to build.key.epoch,
"access_role_id" to build.roleIdHex,
),
)
return 0
}
}
/** `concord channel privatize COMMUNITY CHANNEL [--role NAME]`. */
private suspend fun privatize(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val channelRef = args.positional(1, "channel")
val roleName = args.flag("role")
args.rejectUnknown()
val store = ConcordStore(dataDir.concordFile)
val sc = store.find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val loaded = ConcordModCommands.load(ctx, sc, dataDir)
val (cp, editions) = loaded
ConcordModCommands.writeGuard(cp)?.let { return it }
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
if (!canManageChannels(state.authority, ctx.signer.pubKey)) return forbidden()
val standing = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not in the folded Control Plane")
if (standing.private) return Output.error("already_private", "channel '$channelRef' is already private")
// The next channel epoch must climb past every generation ever used — including ones this
// account never held — so probe the rekey addresses the roots derive (CORD-03 §2).
val entry = ConcordCommands.entryFor(loaded.community)
val window = HashMap<HexKey, Long>()
for (root in (listOf(entry.root) + entry.heldRoots.map { it.key }).distinct()) {
for (epoch in 1L..MAX_PROBED_CHANNEL_EPOCH) window[ConcordChannelRekey.address(root.hexToByteArray(), channelId.hexToByteArray(), epoch).publicKeyHex] = epoch
}
val relays = ConcordCommands.relaysFor(ctx, sc)
val seen = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(window.keys.toList())) }).map { it.second }
val floor = seen.mapNotNull { window[it.pubKey] }.maxOrNull() ?: 0
if (floor >= MAX_PROBED_CHANNEL_EPOCH) return Output.error("inconclusive", "this channel has rotated at least $MAX_PROBED_CHANNEL_EPOCH times; its next epoch can't be established safely")
val build =
ConcordPrivateChannels.privatize(ctx.signer, cp, entry, channelId, standing, roleName, editions, state.authority, TimeUtils.now(), floor)
?: return Output.error("forbidden", "no rank to mint this channel's access Role from")
if (!storeKey(store, loaded.community, build.key)) return Output.error("conflict", "could not store the new channel key")
publishAll(ctx, sc, build.wraps)?.let { return it }
Output.emit(mapOf("channel_id" to channelId, "private" to true, "channel_epoch" to build.key.epoch, "access_role_id" to build.roleIdHex))
return 0
}
}
/** `concord channel publicize COMMUNITY CHANNEL`. */
private suspend fun publicize(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val channelRef = args.positional(1, "channel")
args.rejectUnknown()
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val (cp, editions) = ConcordModCommands.load(ctx, sc, dataDir)
ConcordModCommands.writeGuard(cp)?.let { return it }
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
if (!canManageChannels(state.authority, ctx.signer.pubKey)) return forbidden()
val standing = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not in the folded Control Plane")
val wrap =
ConcordPrivateChannels.publicize(ctx.signer, cp, sc.communityId.hexToByteArray(), channelId, standing, editions, sc.owner, TimeUtils.now())
?: return Output.error("already_public", "channel '$channelRef' is not private")
publishAll(ctx, sc, listOf(wrap))?.let { return it }
Output.emit(mapOf("channel_id" to channelId, "private" to false))
return 0
}
}
/** `concord channel rekey COMMUNITY CHANNEL` — rotate to exactly the members entitled today. */
private suspend fun rekey(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val handle = args.positional(0, "community")
val channelRef = args.positional(1, "channel")
args.rejectUnknown()
val store = ConcordStore(dataDir.concordFile)
val sc = store.find(handle) ?: return ConcordCommands.notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
if (ConcordCommands.isDissolved(ctx, sc)) return Output.error("dissolved", "community '$handle' has been dissolved (CORD-02 §9)")
val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val loaded = ConcordModCommands.load(ctx, sc, dataDir)
val authority = AuthorityResolver.resolve(loaded.editions, sc.communityId.hexToByteArray(), sc.owner)
// The known role holders a rotation to the entitled set leaves out; a roleless member ranks
// last, so any MANAGE_CHANNELS holder outranks them and they need no check.
val keep = ConcordPrivateChannels.keepSet(authority, channelId, ctx.signer.pubKey)
val cut = (authority.roleHolders() + authority.owner()).filterTo(HashSet()) { it !in keep }
return rotate(ctx, store, loaded.community, channelId, authority, loaded.editions, cut)
}
}
/** A rotation's result: [error] (code to message) when refused or unpublished, else what landed. */
internal class Rotation(
val error: Pair<String, String>? = null,
val newEpoch: Long = 0,
val kept: Int = 0,
val chunks: Int = 0,
)
/** [rotateSilently] with its outcome emitted as the command's JSON line. */
internal suspend fun rotate(
ctx: Context,
store: ConcordStore,
sc: StoredCommunity,
channelId: HexKey,
authority: AuthorityResolver,
editions: List<ControlEdition>,
cut: Set<HexKey>,
): Int {
val r = rotateSilently(ctx, store, sc, channelId, authority, editions, cut)
r.error?.let { (code, message) -> return Output.error(code, message) }
Output.emit(mapOf("channel_id" to channelId, "rekeyed" to true, "channel_epoch" to r.newEpoch, "kept" to r.kept, "chunks" to r.chunks))
return 0
}
/**
* Rotates [channelId] to its entitled set (CORD-06 §1-2), cutting [cut]: authority checked, the
* key reserved in the store before anything publishes (a retry re-delivers the same key), every
* chunk accepted by a relay before the new key is adopted locally. Prints nothing.
*/
internal suspend fun rotateSilently(
ctx: Context,
store: ConcordStore,
sc: StoredCommunity,
channelId: HexKey,
authority: AuthorityResolver,
editions: List<ControlEdition>,
cut: Set<HexKey>,
): Rotation {
val me = ctx.signer.pubKey
val entry = ConcordCommands.entryFor(sc)
val held = ConcordChannelKeyring.heldKey(entry, channelId) ?: return Rotation("no_channel_key" to "this account holds no key for channel $channelId, so it cannot rotate it")
if (!ConcordPrivateChannels.canRotate(authority, me, cut)) {
return Rotation("forbidden" to "rotating needs the Manage-channels permission and outranking every member it cuts (CORD-06 §3)")
}
val citation = ConcordReceive.rotationCitation(entry, editions, me)
if (citation == null && !authority.isOwner(me)) return Rotation("forbidden" to "no Grant of ours to cite; nobody would honor this rotation (CORD-06 §3)")
val newEpoch = held.epoch + 1
val reservation = "${held.channelId.lowercase()}:$newEpoch:${ConcordChannelRekey.prevCommit(held.epoch, held.key.hexToByteArray())}"
val newKeyHex = sc.pendingChannelRotations[reservation] ?: ConcordChannelRekey.mintKey().toHexKey()
store.upsert(sc.copy(pendingChannelRotations = sc.pendingChannelRotations + (reservation to newKeyHex)))
val keep = ConcordPrivateChannels.keepSet(authority, channelId, me)
val wraps = ConcordPrivateChannels.buildRotation(ctx.signer, sc.root.hexToByteArray(), held, newKeyHex.hexToByteArray(), keep, TimeUtils.now(), citation)
val relays = ConcordCommands.relaysFor(ctx, sc)
for (wrap in wraps) {
if (ctx.publish(wrap, relays).values.none { it.accepted }) {
return Rotation("rejected" to "no relay accepted chunk ${wrap.id} of the rotation; re-running re-delivers the same key")
}
}
// Adopt at once: the rotator must never keep writing under the severed key.
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
val next = ConcordChannelKeyring.withRotatedKey(ConcordCommands.entryFor(fresh), channelId, newKeyHex, newEpoch)
if (next != null) store.upsert(ConcordCommands.storedFrom(fresh, next).copy(pendingChannelRotations = fresh.pendingChannelRotations - reservation))
return Rotation(newEpoch = newEpoch, kept = keep.size, chunks = wraps.size)
}
/**
* Follows every held Private Channel's rotations for [sc] (CORD-06 §2): drains the watched
* channel-rekey addresses, adopts a key carried off the one we hold, or drops the channel (and
* records the cut) when a rotation from someone who outranks us left us out. Returns one result
* per channel acted on.
*/
internal suspend fun drainChannelRekeys(
ctx: Context,
store: ConcordStore,
sc: StoredCommunity,
): List<Map<String, Any?>> {
val entry = ConcordCommands.entryFor(sc)
val keys = ConcordPrivateChannels.watchKeys(entry)
if (keys.isEmpty()) return emptyList()
val relays = ConcordCommands.relaysFor(ctx, sc)
ctx.registerConcordStreamKeys(relays, keys.values.map { it.secretKey })
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(keys.keys.toList())) }, pendingOnAuthRequired = true).map { it.second }
if (wraps.isEmpty()) return emptyList()
val loaded = ConcordModCommands.load(ctx, sc)
val authority = AuthorityResolver.resolve(loaded.editions, sc.communityId.hexToByteArray(), sc.owner)
val outcomes = ConcordPrivateChannels.receive(entry, wraps, loaded.editions, authority, ctx.signer)
if (outcomes.isEmpty()) return emptyList()
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
val next = ConcordPrivateChannels.applyOutcome(ConcordCommands.entryFor(fresh), outcomes, entry.privateChannels.associate { it.channelId.lowercase() to it.epoch })
if (next != null) store.upsert(ConcordCommands.storedFrom(fresh, next))
return outcomes.map { (id, outcome) ->
when (outcome) {
is ChannelRekeyOutcome.Adopted -> mapOf("community_id" to sc.communityId, "channel_id" to id, "adopted" to true, "channel_epoch" to outcome.epoch)
is ChannelRekeyOutcome.Removed -> mapOf("community_id" to sc.communityId, "channel_id" to id, "removed" to true, "channel_epoch" to outcome.epoch)
ChannelRekeyOutcome.None -> mapOf("community_id" to sc.communityId, "channel_id" to id)
}
}
}
/**
* After a Grant: vends every Private Channel it opened to its member by Direct Invite (only those
* channels), and rotates every one it closed (CORD-03/06; Armada `handleToggleRole`). Returns what
* it did, for the grant command's output.
*/
internal suspend fun reconcileAccess(
ctx: Context,
store: ConcordStore,
sc: StoredCommunity,
before: AuthorityResolver,
afterEditions: List<ControlEdition>,
): Map<String, Any?> {
val state = ConcordCommunityState.fold(afterEditions, sc.communityId.hexToByteArray(), sc.owner)
val me = ctx.signer.pubKey.lowercase()
val entry = ConcordCommands.entryFor(sc)
val changes = ConcordInviteVend.accessChanges(before, state.authority, state.privateChannelIds)
val vended = mutableListOf<Map<String, Any?>>()
val rotated = mutableListOf<String>()
val unrotated = mutableListOf<Map<String, String>>()
val unheld = mutableListOf<String>()
val byMember = HashMap<HexKey, MutableSet<HexKey>>()
for (change in changes) {
if (ConcordChannelKeyring.heldKey(entry, change.channelIdHex) == null) {
unheld += change.channelIdHex
continue
}
for (m in change.gained - me) byMember.getOrPut(m) { HashSet() }.add(change.channelIdHex)
val cut = change.lost - me
if (cut.isNotEmpty()) {
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
val r = rotateSilently(ctx, store, fresh, change.channelIdHex, state.authority, afterEditions, cut)
if (r.error == null) rotated += change.channelIdHex else unrotated += mapOf("channel_id" to change.channelIdHex, "reason" to r.error.second)
}
}
for ((member, channels) in byMember) {
val draft = ConcordActions.draftDirectInvite(entry, state, me, member, onlyChannelIds = channels) as? ConcordDirectInviteDraft.Ready ?: continue
val wrap = ConcordActions.buildDirectInvite(ctx.signer, member, draft.invite)
val lists = ctx.cachedRelayListsOf(member) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, member, ctx.bootstrapRelays())
val ack = ctx.publish(wrap, ConcordActions.directInviteDeliveryRelays(lists))
vended += mapOf("member" to member, "channels" to draft.invite.channels.map { it.id }, "delivered" to ack.values.any { it.accepted })
}
return mapOf("channel_keys_vended" to vended, "channels_rotated" to rotated, "channels_not_rotated" to unrotated, "channels_not_held" to unheld)
}
}
@@ -57,6 +57,13 @@ data class StoredCommunity(
// 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(),
// Per Private Channel, the channel epoch whose rotation cut this account out (CORD-06 §2; the
// reference client's `channel_cuts`): a key below it is never adopted again from a bundle.
val channelCuts: Map<String, Long> = emptyMap(),
// Channel keys reserved for a Private Channel rotation this account started but has not yet
// adopted, keyed "channelId:newEpoch:prevcommit" (CORD-06): a retried `channel rekey` must
// re-deliver the SAME key, never a sibling that splits the members at one epoch.
val pendingChannelRotations: Map<String, String> = emptyMap(),
// 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,
@@ -33,6 +33,7 @@ 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.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
@@ -211,24 +212,30 @@ object ConcordActions {
/**
* 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.
* - Public: its plane under every held prior root ([historicalChannelPlanes]), plus every
* private-era plane a channel key is held for (a channel that was Private before);
* - Private: the planes of the older channel keys the entry still carries (its `seed` and a
* peer's `priors`, [ConcordChannelKeyring.historicalKeys]) — history across a channel rekey.
* Never the root-derived plane: every member reads that one, so showing it would present
* public content as private (Armada `channelsView`).
*/
fun historicalChannelPlanes(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
isPrivate: Boolean,
): List<ChannelPlane> {
if (isPrivate) return emptyList()
val channelId = channelIdHex.hexToByteArray()
val olderKeys =
ConcordChannelKeyring.historicalKeys(entry, channelIdHex).map { old ->
ChannelPlane(channelIdHex, old.epoch, ConcordChannelKeys.privateChannel(old.key.hexToByteArray(), channelId, old.epoch))
}
if (isPrivate) return olderKeys
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))
ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelId, held.epoch))
}
return rootEras + listOfNotNull(privateEra)
return rootEras + listOfNotNull(privateEra) + olderKeys
}
/**
@@ -681,6 +688,7 @@ object ConcordActions {
expiresAtMs: Long? = null,
name: String = entry.name,
icon: ImagePointer? = null,
onlyChannelIds: Set<HexKey>? = null,
): CommunityInvite =
CommunityInvite(
communityId = entry.id,
@@ -689,7 +697,12 @@ object ConcordActions {
communityRoot = entry.root,
rootEpoch = entry.rootEpoch,
controlPk = entry.controlPk,
channels = ConcordInviteVend.toInviteChannels(ConcordInviteVend.vendableChannels(entry.privateChannels, authority, recipient)),
channels =
ConcordInviteVend.toInviteChannels(
ConcordInviteVend
.vendableChannels(entry.privateChannels, authority, recipient)
.filter { onlyChannelIds == null || it.channelId.lowercase() in onlyChannelIds },
),
relays = entry.relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS),
name = name.ifBlank { entry.name },
icon = icon,
@@ -710,6 +723,7 @@ object ConcordActions {
sender: HexKey,
recipient: HexKey,
expiresAtMs: Long? = null,
onlyChannelIds: Set<HexKey>? = null,
): ConcordDirectInviteDraft {
val to = recipient.lowercase()
if (!HEX64.matches(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.INVALID_RECIPIENT)
@@ -724,6 +738,7 @@ object ConcordActions {
expiresAtMs = expiresAtMs,
name = state.metadata?.name ?: entry.name,
icon = state.metadata?.icon,
onlyChannelIds = onlyChannelIds?.mapTo(HashSet()) { it.lowercase() },
),
)
}
@@ -0,0 +1,387 @@
/*
* 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.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
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.cord04Roles.RoleScope
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRotation
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRotationAuthority
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
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.signers.NostrSigner
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* A Private Channel just minted or privatised (CORD-03 §2): its [channelIdHex], the independent
* [key] to store in the Community List **before** [wraps] publish (a lost List write would orphan
* the only copy), the access Role minted beside it ([roleIdHex]), and the Control editions to
* publish in order — the Role first (an orphan Role is inert), then the channel edition.
*/
class PrivateChannelBuild(
val channelIdHex: HexKey,
val key: PrivateChannelKey,
val roleIdHex: HexKey,
val wraps: List<Event>,
)
/**
* Private Channels end to end (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) as pure
* builders and decisions, shared by the app and `amy`. The network and the Community List write
* stay with the caller.
*
* Who may read a Private Channel is its Roles: a Role scoped `{kind:"channel", channel_id}` IS its
* access list ([ConcordInviteVend.isEntitled]). Read access is enforced by key possession alone, so
* this decides who a key is delivered TO (a Direct Invite on grant) and who a rotation keeps (a
* channel rekey on revoke). Pinned to Armada's `channelAccess.ts`, `useCommunityActions`
* (`createChannel`, `privatiseChannel`, `publiciseChannel`) and `useRekey` (`useChannelRekey`,
* `useChannelRekeyWatch`).
*/
object ConcordPrivateChannels {
/**
* The position a new access Role takes (Armada `accessRolePosition`): the bottom of the roster,
* never above what [actor] may mint (the owner mints from 1, anyone else strictly below their
* own rank), so a grantee is never promoted by being let into a channel. Null when [actor] holds
* no rank to mint from.
*/
fun accessRolePosition(
authority: AuthorityResolver?,
actor: HexKey,
owner: HexKey,
): Long? {
val ceiling =
if (actor.equals(owner, ignoreCase = true)) {
1L
} else {
(authority?.rank(actor) ?: return null) + 1
}
val lowest =
authority
?.roles()
?.values
?.filterNot { it.deleted }
?.maxOfOrNull { it.position } ?: 0L
return maxOf(ceiling, lowest + 1)
}
/** [name] cut to the protocol's 64-byte cap on a character boundary (Armada slices the same way). */
private fun fitName(name: String): String {
var out = name
while (!ConcordLimits.nameFits(out)) out = out.dropLast(1)
return out
}
/** The bit-less access Role scoped to [channelIdHex] (CORD-04 §2): read access is the key, never a bit. */
fun accessRole(
name: String,
channelIdHex: HexKey,
position: Long,
): RoleEntity =
RoleEntity(
name = fitName(name),
position = position,
permissions = "0",
scope = RoleScope(kind = "channel", channelId = channelIdHex.lowercase()),
)
/**
* A new channel (CORD-03 §2): a random `channel_id`, and for a Private one an independent key
* at channel epoch 0 plus its access Role (Armada `createChannel`: a born-private channel is
* epoch 0; the first *privatisation* of a public channel is epoch 1). Returns null when the
* name is invalid or the actor has no rank to mint the Role from.
*/
suspend fun create(
actor: NostrSigner,
cp: ControlPlaneKeys,
communityId: ByteArray,
name: String,
accessRoleName: String?,
current: List<ControlEdition>,
authority: AuthorityResolver?,
owner: HexKey,
createdAt: Long,
): PrivateChannelBuild? {
val channel = ChannelEntity(name = name.trim(), private = true)
if (!channel.hasValidName()) return null
val position = accessRolePosition(authority, actor.pubKey, owner) ?: return null
val channelId = RandomInstance.bytes(32)
val channelIdHex = channelId.toHexKey()
val roleId = RandomInstance.bytes(32)
val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: channel.name, channelIdHex, position)
val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = owner)
val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelId, channel, current, createdAt, owner = owner)
return PrivateChannelBuild(
channelIdHex = channelIdHex,
key = PrivateChannelKey(channelIdHex, ConcordChannelRekey.mintKey().toHexKey(), 0, channel.name),
roleIdHex = roleId.toHexKey(),
wraps = listOf(roleWrap, channelWrap),
)
}
/**
* Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh independent key at
* the NEXT channel epoch ([ConcordChannelKeyring.nextChannelEpoch], floored at [observedFloor],
* the highest channel rotation seen on the wire), a new access Role, and the channel edition
* flipping `private` on — every other field of [standing] carried through. Protects the future
* only: the public era stays readable to every member. Null when the actor can't mint the Role.
*/
suspend fun privatize(
actor: NostrSigner,
cp: ControlPlaneKeys,
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
standing: ChannelEntity,
accessRoleName: String?,
current: List<ControlEdition>,
authority: AuthorityResolver?,
createdAt: Long,
observedFloor: Long = 0,
): PrivateChannelBuild? {
if (standing.private || standing.deleted) return null
val position = accessRolePosition(authority, actor.pubKey, entry.owner) ?: return null
val communityId = entry.id.hexToByteArray()
val roleId = RandomInstance.bytes(32)
val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: standing.name, channelIdHex, position)
val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = entry.owner)
val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = true), current, createdAt, owner = entry.owner)
val epoch = ConcordChannelKeyring.nextChannelEpoch(entry, channelIdHex, observedFloor)
return PrivateChannelBuild(
channelIdHex = channelIdHex.lowercase(),
key = PrivateChannelKey(channelIdHex.lowercase(), ConcordChannelRekey.mintKey().toHexKey(), epoch, standing.name),
roleIdHex = roleId.toHexKey(),
wraps = listOf(roleWrap, channelWrap),
)
}
/**
* Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The
* channel derives from the `community_root` from here on; the held key stays in the List so its
* holders keep reading the private era, which a later joiner never can. Null when it is not
* private.
*/
suspend fun publicize(
actor: NostrSigner,
cp: ControlPlaneKeys,
communityId: ByteArray,
channelIdHex: HexKey,
standing: ChannelEntity,
current: List<ControlEdition>,
owner: HexKey,
createdAt: Long,
): Event? {
if (!standing.private || standing.deleted) return null
return ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = false), current, createdAt, owner = owner)
}
// ---- channel rotations (CORD-06 §1-2) -------------------------------------
/**
* Whether [actor] may launch a single-channel Rekey cutting [removed] (CORD-06 §3 Authority):
* `MANAGE_CHANNELS` (or `BAN`, a Refounding's) and strictly outranking every removed target.
* The owner may always; the owner is never a valid target.
*/
fun canRotate(
authority: AuthorityResolver,
actor: HexKey,
removed: Collection<HexKey>,
bit: Int = ConcordPermissions.MANAGE_CHANNELS,
): Boolean {
if (authority.isOwner(actor)) return true
if (!authority.hasPermission(actor, bit)) return false
return removed.all { it.equals(actor, ignoreCase = true) || authority.canActOn(actor, it, bit) }
}
/**
* The members a rotation of [channelIdHex] keeps (Armada `handleRotateChannelKey`): exactly
* those entitled today ([ConcordInviteVend.entitledMembers]) plus the [rotator], who must keep
* every key or nobody could rotate the channel next time.
*/
fun keepSet(
authority: AuthorityResolver,
channelIdHex: HexKey,
rotator: HexKey,
): Set<HexKey> = ConcordInviteVend.entitledMembers(authority, channelIdHex) + rotator.lowercase()
/**
* The wraps of one rotation of [held] to [newKey] for [keep], sealed under [sealingRoot] (the
* current root for a single-channel Rekey, the PRIOR root inside a Refounding — CORD-06 §3), and
* citing [authority] on every chunk.
*/
suspend fun buildRotation(
rotator: NostrSigner,
sealingRoot: ByteArray,
held: PrivateChannelKey,
newKey: ByteArray,
keep: Collection<HexKey>,
createdAt: Long,
authority: AuthorityCitation?,
): List<Event> =
ConcordChannelRekey.build(
rotatorSigner = rotator,
sealingRoot = sealingRoot,
channelId = held.channelId.hexToByteArray(),
heldKey = held.key.hexToByteArray(),
heldEpoch = held.epoch,
newKey = newKey,
recipients = keep + rotator.pubKey,
createdAt = createdAt,
authority = authority,
)
/** The roots a member watches channel rekeys under: the current one and the canonical prior one (CORD-06 §3). */
fun watchRoots(entry: ConcordCommunityListEntry): List<ByteArray> {
val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).filter { it.epoch == entry.rootEpoch - 1 }
return (listOf(entry.root) + prior.map { it.key }).distinct().map { it.hexToByteArray() }
}
/**
* Every channel-rekey address this entry should watch (CORD-06 §2): per held Private Channel,
* the next [ConcordChannelRekey.LOOKAHEAD] channel epochs past the held one, under each of
* [watchRoots]. Address hex → key.
*/
fun watchKeys(entry: ConcordCommunityListEntry): Map<HexKey, GroupKey> {
val out = LinkedHashMap<HexKey, GroupKey>()
val roots = watchRoots(entry)
for (held in entry.privateChannels) {
if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue
val channelId = held.channelId.hexToByteArray()
for (root in roots) {
for (ahead in 1..ConcordChannelRekey.LOOKAHEAD) {
val key = ConcordChannelRekey.address(root, channelId, held.epoch + ahead)
out[key.publicKeyHex] = key
}
}
}
return out
}
/**
* Whether a received channel rotation's Rotator may be honored (CORD-06 §3 Authority): the owner,
* or a non-banned holder of `MANAGE_CHANNELS` (a single-channel Rekey) or `BAN` (a Refounding's
* channel rekeys), whose `vac` cites a Grant our fold has synced. Key possession is never
* authority.
*/
fun isHonoredRotation(
entry: ConcordCommunityListEntry,
editions: Collection<ControlEdition>,
authority: AuthorityResolver,
rotation: ChannelRotation,
): Boolean {
val rotator = rotation.rotator
if (!authority.isOwner(rotator)) {
if (authority.isBanned(rotator)) return false
if (!authority.hasPermission(rotator, ConcordPermissions.MANAGE_CHANNELS) && !authority.hasPermission(rotator, ConcordPermissions.BAN)) return false
}
val heads = ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner)
return ConcordRotationAuthority.citationSatisfied(entry.id, rotator, entry.owner, rotation.authority, heads)
}
/** Whether [rotator] strictly outranks [me] — only such a Rotator's omission is a cut (CORD-06 §3). */
fun outranks(
authority: AuthorityResolver,
rotator: HexKey,
me: HexKey,
): Boolean {
if (authority.isOwner(me)) return false
val theirs = authority.rank(rotator) ?: return false
val mine = authority.rank(me) ?: Long.MAX_VALUE
return theirs < mine
}
/**
* What the buffered channel-rekey [wraps] mean for each Private Channel [entry] holds a key for
* (CORD-06 §2), per channel id: an adoption moves that channel's key forward, a cut drops it and
* records `channel_cuts`. Channels with nothing to do are omitted. The caller applies the result
* inside its List write ([applyOutcome]).
*/
suspend fun receive(
entry: ConcordCommunityListEntry,
wraps: Collection<Event>,
editions: Collection<ControlEdition>,
authority: AuthorityResolver,
recipient: NostrSigner,
): Map<HexKey, ChannelRekeyOutcome> {
if (wraps.isEmpty()) return emptyMap()
val keys = watchKeys(entry)
val me = recipient.pubKey
val joinedAtSecs = entry.addedAt / 1000
val out = LinkedHashMap<HexKey, ChannelRekeyOutcome>()
for (held in entry.privateChannels) {
if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue
val id = held.channelId.lowercase()
val rotations = ConcordChannelRekey.rotations(wraps, keys, id)
if (rotations.isEmpty()) continue
val outcome =
ConcordChannelRekey.walk(
rotations = rotations,
channelIdHex = id,
heldKey = held.key.hexToByteArray(),
heldEpoch = held.epoch,
recipientSigner = recipient,
joinedAtSecs = joinedAtSecs,
honored = { isHonoredRotation(entry, editions, authority, it) },
outranksMe = { outranks(authority, it, me) },
)
if (outcome !is ChannelRekeyOutcome.None) out[id] = outcome
}
return out
}
/**
* [current] with [outcomes] applied — only where the channel is still at the epoch the outcome
* was computed from ([fromEpochs]), so a write racing another adoption never rolls a key back —
* or null when nothing changed.
*/
fun applyOutcome(
current: ConcordCommunityListEntry,
outcomes: Map<HexKey, ChannelRekeyOutcome>,
fromEpochs: Map<HexKey, Long>,
): ConcordCommunityListEntry? {
var next = current
for ((id, outcome) in outcomes) {
val held = ConcordChannelKeyring.heldKey(next, id) ?: continue
if (held.epoch != fromEpochs[id]) continue
next =
when (outcome) {
is ChannelRekeyOutcome.Adopted -> ConcordChannelKeyring.withRotatedKey(next, id, outcome.key.toHexKey(), outcome.epoch) ?: next
is ChannelRekeyOutcome.Removed -> ConcordChannelKeyring.withoutChannel(next, id, outcome.epoch)
ChannelRekeyOutcome.None -> next
}
}
return if (next === current) null else next
}
}
@@ -96,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), and the dissolution tombstone address (CORD-02 §9). All are kind-1059
* CORD-06), the dissolution tombstone address (CORD-02 §9), and every held Private Channel's
* next channel-rekey addresses (CORD-06 §2). All are kind-1059
* wraps authored by their derived stream address.
*/
fun auxiliaryPlaneSubs(entries: List<ConcordCommunityListEntry>): List<ConcordPlaneSub> =
@@ -114,7 +115,10 @@ object ConcordSubscriptionPlanner {
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) },
)
) +
// Each held Private Channel's next channel-rekey addresses (CORD-06 §2), so a rotation
// that moves the key forward — or cuts us — is received live.
ConcordPrivateChannels.watchKeys(e).keys.map { ConcordPlaneSub(channelId = null, pubKeyHex = it, relays = relays) }
}
/**
@@ -4185,6 +4185,11 @@ class Account(
// A promotion to staff delivers the Control Plane write key inside the Grant
// itself (CORD-04 §3), so the fold that seats the role is also when it arrives.
runCatching { concord.drainConcordStaffGrants() }.onFailure { Log.w("Concord", "staff grant drain failed", it) }
// A Private Channel rotation lands on its channel-rekey address (CORD-06 §2): adopt the new
// key, or drop the channel when it cut us.
runCatching { concord.drainConcordChannelRekeys() }.onFailure { Log.w("Concord", "channel rekey drain failed", it) }
// A Grant folding late turns a parked catch-up invite into one we adopt without a click.
runCatching { concord.drainConcordCatchUps() }.onFailure { Log.w("Concord", "catch-up drain failed", it) }
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
// found by re-resolving the invite link we joined through. Rate-limited internally.
runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordPinContext
import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome
import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays
@@ -53,9 +54,11 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordListTooLargeExcep
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
@@ -70,8 +73,11 @@ 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.ConcordInviteRegistry
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
@@ -139,6 +145,9 @@ private const val PIN_REHEAL_RETRIES = 2
*/
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
// How many channel epochs a privatisation probes for earlier rotations (Armada MAX_PROBED_CHANNEL_EPOCH).
private const val MAX_PROBED_CHANNEL_EPOCH = 32L
/** A lowercase 32-byte hex key (the Guestbook `invite` tag's creator). */
private val HEX64 = Regex("^[0-9a-f]{64}$")
@@ -199,6 +208,21 @@ class AccountConcordActions(
/** Adds or replaces [entry] in the Community List; false when it could not be written. */
private suspend fun persistConcordEntry(entry: ConcordCommunityListEntry): Boolean = writeConcordList { it.follow(entry) }
/**
* Rewrites the held entry for [communityId] through [transform], applied to the entry **as the
* List holds it inside the write** ([ConcordChannelListState.update]) — never to a snapshot read
* before a suspension, which a concurrent rekey or import could have moved on. True only when
* [transform] produced a change and it was written.
*/
private suspend fun updateConcordEntry(
communityId: String,
transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?,
): Boolean {
var changed = false
val ok = writeConcordList { list -> list.update(communityId) { cur -> transform(cur)?.also { changed = true } } }
return ok && changed
}
/** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */
private suspend fun announceConcordGuestbookJoin(
entry: ConcordCommunityListEntry,
@@ -813,7 +837,8 @@ class AccountConcordActions(
account.concordChannelList.liveCommunities,
account.concordChannelList.removedAt,
account.hiddenUsers.flow,
account.kind3FollowList.flow,
// A fold landing can make a parked catch-up admissible or not (the sender's standing).
combine(account.kind3FollowList.flow, account.concordSessions.revision) { follows, _ -> follows },
) { pending, joined, removedAt, _, follows ->
ConcordDirectInviteInbox.visible(
pending.values,
@@ -821,6 +846,12 @@ class AccountConcordActions(
removedAt = removedAt,
isFollowed = { it in follows.authors },
isHidden = { account.isHidden(it) },
heldStateOf = { id ->
account.concordSessions
.sessionFor(id)
?.state
?.value
},
)
}.stateIn(account.scope, SharingStarted.WhileSubscribed(5_000), emptyList())
@@ -847,9 +878,42 @@ class AccountConcordActions(
val filter = ConcordActions.directInvitesFilter(account.signer.pubKey, directInviteInbox.since())
val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(filter) })
wraps.distinctBy { it.id }.forEach { directInviteInbox.offer(it) }
drainConcordCatchUps()
return (directInviteInbox.pending.value.keys - before).size
}
/**
* Adopts, without a click, every parked catch-up the held fold says is exactly the delivery a
* Grant prescribes (Armada `judgeCatchUp`, [ConcordInviteVend.judgeCatchUp]): a staff sender, a
* recipient who isn't banned, and only live Private Channels our Roles entitle us to. Consent
* came from the Grant. Anything else waits for a manual Accept. Runs after an inbox sweep and on
* the revision tick (a Grant folding late turns a waiting catch-up adoptable).
*/
internal suspend fun drainConcordCatchUps() {
if (!account.isWriteable()) return
val me = account.signer.pubKey
val now = TimeUtils.nowMillis()
for (opened in directInviteInbox.pending.value.values) {
if (opened.isExpired(now)) continue
val held =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id.equals(opened.invite.communityId, ignoreCase = true) } ?: continue
val state =
account.concordSessions
.sessionFor(held.id)
?.state
?.value ?: continue
if (state.dissolved) continue
val verdict = ConcordInviteVend.judgeCatchUp(state.authority, state.privateChannelIds, me, opened.sender, opened.invite, held)
if (verdict != ConcordInviteVend.CatchUpVerdict.ADOPT) continue
val ids = ConcordInviteVend.catchUpChannelIds(held, opened.invite)
if (updateConcordEntry(held.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, opened.invite, ids) }) {
Log.i("Concord") { "Adopted a granted Private Channel key for ${held.id} from ${opened.sender}" }
directInviteInbox.resolve(opened.wrapId)
}
}
}
/**
* The recipient's giftwrap inbox (CORD-05 §6): their kind-10050 DM relays, else NIP-65 read
* relays — from the cache when we have their lists, fetched otherwise — else the stock set.
@@ -885,6 +949,7 @@ class AccountConcordActions(
communityId: String,
recipientPubKey: HexKey,
expiresAtMs: Long? = null,
onlyChannelIds: Set<HexKey>? = null,
): ConcordDirectInviteSendResult {
if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE
val recipient = recipientPubKey.lowercase()
@@ -898,7 +963,7 @@ class AccountConcordActions(
?.state
?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED
val invite =
when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs)) {
when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs, onlyChannelIds)) {
is ConcordDirectInviteDraft.Refused -> return draft.reason
is ConcordDirectInviteDraft.Ready -> draft.invite
}
@@ -940,9 +1005,12 @@ class AccountConcordActions(
// No folded roster yet: whether it bans us is unknown, so the invite waits.
DirectInviteAcceptPlan.RosterNotLoaded -> ConcordInviteResult.NotReachable
DirectInviteAcceptPlan.NothingNew -> ConcordInviteResult.Joined(bundle.communityId)
// Keys only, on the held base: no second Guestbook Join.
is DirectInviteAcceptPlan.CatchUp ->
if (persistConcordEntry(plan.entry)) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable
// Keys only, on the held base: no second Guestbook Join. Re-applied to the entry the
// List holds at write time, so a root imported meanwhile is never written back over.
is DirectInviteAcceptPlan.CatchUp -> {
val ok = writeConcordList { list -> list.update(plan.entry.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, bundle, plan.channelIds) } }
if (ok) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable
}
DirectInviteAcceptPlan.Join ->
joinValidatedConcordInvite(
bundle = bundle,
@@ -1366,6 +1434,7 @@ class AccountConcordActions(
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
val before = session.state.value?.authority
// A Grant that first makes its member staff must deliver the control_root in the same
// edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the
// roles carry a Control-writing bit and we hold the secret to hand over.
@@ -1383,6 +1452,8 @@ class AccountConcordActions(
epoch = session.entry.rootEpoch,
)
publishConcordWrap(session.entry, wrap)
// Role-gated channel keys follow the Grant: vend what it opened, rotate what it closed.
reconcileConcordChannelAccess(communityId, before)
return true
}
@@ -1488,8 +1559,10 @@ class AccountConcordActions(
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
val before = session.state.value?.authority
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, grantWrap)
reconcileConcordChannelAccess(communityId, before)
return true
}
@@ -1546,14 +1619,20 @@ class AccountConcordActions(
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
val before = session.state.value?.authority
val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
// Judged on the fold that now carries the ban (publishConcordWrap ingests it first).
val state = session.state.value
if (state != null && state.banRequiresRefounding(listOf(member))) {
// The Refounding rotates every held Private Channel to its entitled set (CORD-06 §3).
if (!refoundConcordCommunity(communityId, setOf(member))) {
Log.w("Concord") { "Banned $member from the Private community $communityId, but its Refounding did not complete" }
}
} else {
// A Public ban keeps the base, so the Private Channels the target could read are cut by
// their own rekeys (CORD-04 §6: "a Private-Channel rekey for a channel-scoped cut").
reconcileConcordChannelAccess(communityId, before)
}
return true
}
@@ -1923,9 +2002,14 @@ class AccountConcordActions(
}
if (!compactionLanded) Log.w("Concord") { "Refounding ${entry.id}: some compacted Control Plane heads were not accepted at epoch ${build.newEpoch}" }
// 5b. Rotate every held Private Channel (CORD-06 §3), each to its OWN entitled set among the
// kept members, sealed under the PRIOR root so a base-fork loser can still open it. A
// channel that fails to land keeps its key (and is logged): resumable, not atomic.
val rotatedEntry = rotatePrivateChannelsForRefounding(entry, recipients.toSet(), priorRoot, citation)
// 6. Adopt the new epoch ourselves. This rebuilds our session under the new root and
// re-folds the compacted Control Plane (with the ban), dropping the removed members.
val adopted = adoptConcordRoot(entry, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), keys.newControlRoot)
val adopted = adoptConcordRoot(rotatedEntry, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), keys.newControlRoot)
pendingConcordRefoundings.remove(entry.id)
// 7. Move every link we minted to the new epoch. Without this the Refounding orphans them,
@@ -1937,6 +2021,40 @@ class AccountConcordActions(
return compactionLanded
}
/**
* The Refounding's channel duty (CORD-06 §3): every held key of a live Private Channel is
* rotated to the members still entitled to it ∩ [kept], plus ourselves, sealed under
* [priorRoot]. Returns [entry] with each rotated channel moved to its new key (the caller adopts
* the new root from it); a channel whose rotation didn't land keeps its old key.
*/
private suspend fun rotatePrivateChannelsForRefounding(
entry: ConcordCommunityListEntry,
kept: Set<HexKey>,
priorRoot: ByteArray,
citation: AuthorityCitation?,
): ConcordCommunityListEntry {
val session = account.concordSessions.sessionFor(entry.id) ?: return entry
val state = session.state.value ?: return entry
val me = account.signer.pubKey.lowercase()
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
val keptLower = kept.mapTo(HashSet()) { it.lowercase() }
var next = entry
for (held in entry.privateChannels) {
val id = held.channelId.lowercase()
if (id !in state.privateChannelIds || ConcordChannelKeyring.heldKey(entry, id) == null) continue
val keep = ConcordPrivateChannels.keepSet(state.authority, id, me).filterTo(HashSet()) { it in keptLower || it == me }
val newKey = ConcordChannelRekey.mintKey()
val wraps = ConcordPrivateChannels.buildRotation(account.signer, priorRoot, held, newKey, keep, TimeUtils.now(), citation)
val landed = wraps.all { runCatching { account.client.publishAndConfirm(it, publishTo) }.getOrDefault(false) }
if (!landed) {
Log.w("Concord") { "Refounding ${entry.id}: the rekey of private channel $id did not land; it keeps its key" }
continue
}
next = ConcordChannelKeyring.withRotatedKey(next, id, newKey.toHexKey(), held.epoch + 1) ?: next
}
return next
}
// Keys reserved for a Refounding in flight, per community (CORD-06 §3): a retry of the same
// rotation reuses them. Process-local — a restart mid-rotation mints afresh, which is why the
// rekey chunks are all confirmed before anything is adopted.
@@ -2344,10 +2462,13 @@ class AccountConcordActions(
suspend fun createConcordChannel(
communityId: String,
name: String,
private: Boolean = false,
accessRoleName: String? = null,
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
if (private) return createPrivateConcordChannel(session, cp, communityId, name, accessRoleName)
val channelId = RandomInstance.bytes(32)
val channel = ChannelEntity(name = name.trim())
// Readers drop an empty or over-64-byte name (CORD-03 §2); never mint one.
@@ -2404,6 +2525,244 @@ class AccountConcordActions(
return true
}
// ── Private Channels (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) ──────────────────
// A Private Channel reads on its own independent key. Its access list is the Roles scoped to it:
// a Grant that opens one vends the key by Direct Invite, a Grant (or ban) that closes one rotates
// it to the members still entitled. The protocol decisions live in ConcordPrivateChannels /
// ConcordChannelRekey (shared with `amy`); only the network and the List write are here.
/**
* A new Private Channel (CORD-03 §2): an independent key at channel epoch 0 stored in the List
* FIRST (a lost List write would orphan the only copy), then its access Role and the channel
* edition. Nobody holds the Role yet; granting it vends the key ([grantConcordRole]).
*/
private suspend fun createPrivateConcordChannel(
session: ConcordCommunitySession,
cp: ControlPlaneKeys,
communityId: String,
name: String,
accessRoleName: String?,
): Boolean {
val build =
ConcordPrivateChannels.create(
actor = account.signer,
cp = cp,
communityId = communityId.hexToByteArray(),
name = name,
accessRoleName = accessRoleName,
current = session.controlEditions(),
authority = session.state.value?.authority,
owner = session.entry.owner,
createdAt = TimeUtils.now(),
) ?: return false
if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false
build.wraps.forEach { publishConcordWrap(session.entry, it) }
return true
}
/**
* Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh key at the NEXT
* channel epoch — floored at the highest channel rotation seen on the wire, since a privatiser
* may never have held an earlier generation and a reused epoch is silent and unrecoverable — plus
* a new access Role, then the flag. Protects the future only. MANAGE_CHANNELS.
*/
suspend fun privatizeConcordChannel(
communityId: String,
channelIdHex: HexKey,
accessRoleName: String? = null,
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
val state = session.state.value ?: return false
val standing = state.channels[channelIdHex]?.definition ?: return false
if (standing.private) return false
val floor = observedChannelEpochFloor(session.entry, channelIdHex) ?: return false
val build =
ConcordPrivateChannels.privatize(
actor = account.signer,
cp = cp,
entry = session.entry,
channelIdHex = channelIdHex,
standing = standing,
accessRoleName = accessRoleName,
current = session.controlEditions(),
authority = state.authority,
createdAt = TimeUtils.now(),
observedFloor = floor,
) ?: return false
if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false
build.wraps.forEach { publishConcordWrap(session.entry, it) }
return true
}
/**
* The highest channel epoch a rotation of [channelIdHex] was ever published at, read off the
* rekey addresses every held root derives (CORD-06 §2 addresses need no channel key), or null
* when the read is inconclusive — a rotation at the window's top may have more above it, and
* minting on a guess could reuse an epoch (Armada `channelEpochFloor`). 0 when none is seen or no
* relay answers.
*/
private suspend fun observedChannelEpochFloor(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
): Long? {
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isEmpty()) return 0
val channelId = channelIdHex.hexToByteArray()
val window = HashMap<HexKey, Long>()
for (root in (listOf(entry.root) + entry.heldRoots.map { it.key }).distinct()) {
for (epoch in 1L..MAX_PROBED_CHANNEL_EPOCH) window[ConcordChannelRekey.address(root.hexToByteArray(), channelId, epoch).publicKeyHex] = epoch
}
val seen = runCatching { account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.planeFilterFor(window.keys.toList())) }) }.getOrDefault(emptyList())
val highest = seen.mapNotNull { window[it.pubKey] }.maxOrNull() ?: 0
return if (highest >= MAX_PROBED_CHANNEL_EPOCH) null else highest
}
/**
* Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The
* held key stays, so its holders keep reading the private era. MANAGE_CHANNELS.
*/
suspend fun publicizeConcordChannel(
communityId: String,
channelIdHex: HexKey,
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
val standing =
session.state.value
?.channels
?.get(channelIdHex)
?.definition ?: return false
val wrap = ConcordPrivateChannels.publicize(account.signer, cp, communityId.hexToByteArray(), channelIdHex, standing, session.controlEditions(), session.entry.owner, TimeUtils.now()) ?: return false
publishConcordWrap(session.entry, wrap)
return true
}
// Channel keys reserved for a rotation in flight, keyed by (community, channel, new epoch,
// prevcommit): a retry re-delivers the SAME key rather than minting a sibling that would split
// the members across two keys at one epoch (Armada `mintOrReuseRotationKey`). Process-local.
private val pendingConcordChannelRotations = ConcurrentMap<String, ByteArray>()
/**
* Rotates Private Channel [channelIdHex] (a single-channel Rekey, CORD-06 §1-2) to exactly the
* members entitled to it today plus ourselves, cutting everyone else. [removed] names who the
* rotation cuts, for the authority check — the Rotator must hold MANAGE_CHANNELS and strictly
* outrank each of them; null takes every known member who is not kept. Every chunk must land on
* a relay before we adopt the new key. Returns whether the rotation was published and adopted.
*/
suspend fun rekeyConcordChannel(
communityId: String,
channelIdHex: HexKey,
removed: Set<HexKey>? = null,
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val state = session.state.value ?: return false
if (state.dissolved) return false
val entry = session.entry
val me = account.signer.pubKey
val held = ConcordChannelKeyring.heldKey(entry, channelIdHex) ?: return false
val authority = state.authority
val keep = ConcordPrivateChannels.keepSet(authority, channelIdHex, me)
val cut = removed ?: (session.allMembers() - keep)
if (!ConcordPrivateChannels.canRotate(authority, me, cut)) {
Log.w("Concord") { "Refusing to rotate $channelIdHex in $communityId: not MANAGE_CHANNELS, or does not outrank a cut member (CORD-06 §3)" }
return false
}
val editions = session.controlEditions()
val citation = ConcordReceive.rotationCitation(entry, editions, me)
if (citation == null && !authority.isOwner(me)) return false
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (publishTo.isEmpty()) return false
val newEpoch = held.epoch + 1
val reservation = "${entry.id}:${held.channelId.lowercase()}:$newEpoch:${ConcordChannelRekey.prevCommit(held.epoch, held.key.hexToByteArray())}"
val newKey = pendingConcordChannelRotations.getOrPut(reservation) { ConcordChannelRekey.mintKey() }
val wraps = ConcordPrivateChannels.buildRotation(account.signer, entry.root.hexToByteArray(), held, newKey, keep, TimeUtils.now(), citation)
for (wrap in wraps) {
if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) {
Log.w("Concord") { "Channel rekey of $channelIdHex aborted: a chunk was not accepted by any relay; retrying reuses the same key" }
return false
}
}
// Adopt at once: the rotator must never keep writing under the severed key.
val adopted =
updateConcordEntry(entry.id) { cur ->
if (ConcordChannelKeyring.heldKey(cur, channelIdHex)?.epoch != held.epoch) null else ConcordChannelKeyring.withRotatedKey(cur, channelIdHex, newKey.toHexKey(), newEpoch)
}
if (adopted) pendingConcordChannelRotations.remove(reservation)
return adopted
}
/**
* Follows a roster change with the keys it implies (Armada `handleToggleRole`): every Private
* Channel whose entitled set moved between [before] and the current fold ([ConcordInviteVend.accessChanges])
* — a member who gained one is handed its key by Direct Invite (only the channels gained), and
* one who lost one is cut by rotating it. Only keys we hold can move; a channel we can't vend or
* rotate is logged, since a revoke that cuts nobody is the failure to hear about.
*/
private suspend fun reconcileConcordChannelAccess(
communityId: String,
before: AuthorityResolver?,
) {
val session = account.concordSessions.sessionFor(communityId) ?: return
val state = session.state.value ?: return
if (before == null || state.dissolved) return
val me = account.signer.pubKey.lowercase()
val changes = ConcordInviteVend.accessChanges(before, state.authority, state.privateChannelIds)
if (changes.isEmpty()) return
val vend = HashMap<HexKey, MutableSet<HexKey>>()
for (change in changes) {
val held = ConcordChannelKeyring.heldKey(session.entry, change.channelIdHex)
if (held == null) {
Log.w("Concord") { "Access to ${change.channelIdHex} changed, but we hold no key to vend or rotate it" }
continue
}
for (member in change.gained - me) vend.getOrPut(member) { HashSet() }.add(change.channelIdHex)
val cut = change.lost - me
if (cut.isNotEmpty() && !rekeyConcordChannel(communityId, change.channelIdHex, cut)) {
Log.w("Concord") { "Could not rotate ${change.channelIdHex}: ${cut.size} member(s) may keep reading it until someone who can rotates it" }
}
}
for ((member, channels) in vend) {
val sent = sendConcordDirectInvite(communityId, member, onlyChannelIds = channels)
if (sent != ConcordDirectInviteSendResult.SENT) Log.w("Concord") { "Could not deliver ${channels.size} channel key(s) to $member: $sent" }
}
}
/**
* Drains the buffered channel rekeys of every joined community (CORD-06 §2 receive path): for
* each held Private Channel, a complete, honored rotation carrying our blob off the key we hold
* moves the key forward; a complete one from a Rotator who outranks us that omits us drops it and
* records the cut. Runs on the revision tick; idempotent once applied (the held epoch moves on).
*/
internal suspend fun drainConcordChannelRekeys() {
if (!account.isWriteable()) return
for (session in account.concordSessions.sessions()) {
val state = session.state.value ?: continue
// Death wins every race (CORD-02 §9).
if (state.dissolved) continue
val wraps = session.pendingChannelRekeyWraps()
if (wraps.isEmpty()) continue
val entry = session.entry
val outcomes = ConcordPrivateChannels.receive(entry, wraps, session.controlEditions(), state.authority, account.signer)
if (outcomes.isEmpty()) continue
val fromEpochs = entry.privateChannels.associate { it.channelId.lowercase() to it.epoch }
if (updateConcordEntry(entry.id) { cur -> ConcordPrivateChannels.applyOutcome(cur, outcomes, fromEpochs) }) {
for ((id, outcome) in outcomes) {
when (outcome) {
is ChannelRekeyOutcome.Adopted -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: adopted epoch ${outcome.epoch}" }
is ChannelRekeyOutcome.Removed -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: cut at epoch ${outcome.epoch}" }
ChannelRekeyOutcome.None -> Unit
}
}
}
}
}
/**
* Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from
* the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT
@@ -256,6 +256,28 @@ class ConcordChannelListState(
write(set, doc.entries.filterNot { it.id == entry.id } + live, doc.residue)
}
/**
* Read-modify-write one membership: [transform] receives the entry for [communityId] **as the
* List holds it inside the write lock** and returns its replacement, or null to write nothing.
* Returns the fragment events to publish (empty when the community isn't held or nothing
* changed).
*
* Use this, never [follow] with a snapshot, for any change that touches one field of a live
* entry (a channel key, a cut): the List can move between reading a snapshot and writing it —
* a rekey adopted, a new root imported — and following the stale copy would write the old
* root back over it.
*/
suspend fun update(
communityId: String,
transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?,
): List<Event> =
writeLock.withLock {
val (set, doc) = snapshot()
val current = doc.entries.firstOrNull { it.id == communityId } ?: return@withLock emptyList()
val next = transform(current) ?: return@withLock emptyList()
write(set, doc.entries.map { if (it.id == communityId) next else it }, 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
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordLocalEdit
import com.vitorpamplona.amethyst.commons.actions.ConcordPinSource
import com.vitorpamplona.amethyst.commons.actions.ConcordPinVerifier
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
@@ -283,6 +284,14 @@ class ConcordCommunitySession(
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
private val siblingRekeyWraps = LinkedHashMap<HexKey, Event>()
// Channel-rekey addresses (CORD-06 §2) for each held Private Channel's next epochs, under the
// current root and the prior one (a Refounding seals its channel rekeys under the prior root,
// CORD-06 §3) -> key. Re-derived whenever the held channel keys change, since each adoption
// moves the window forward.
@Volatile
private var channelRekeyKeys: Map<HexKey, GroupKey> = ConcordPrivateChannels.watchKeys(entry)
private val channelRekeyWraps = LinkedHashMap<HexKey, Event>()
// 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
@@ -429,6 +438,7 @@ class ConcordCommunitySession(
address == nextBaseRekeyAddress ||
address == siblingBaseRekeyAddress ||
address == dissolvedAddress ||
address in channelRekeyKeys ||
address in historicalControlKeys ||
lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress }
@@ -514,7 +524,13 @@ class ConcordCommunitySession(
* 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)
fun auxStreamKeys(): List<GroupKey> = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey) + channelRekeyKeys.values
/** The channel-rekey addresses this session watches (CORD-06 §2), for the auxiliary subscription. */
fun channelRekeyAddresses(): Set<HexKey> = channelRekeyKeys.keys
/** The buffered kind-3303 wraps seen at [channelRekeyAddresses], for the account's channel-rekey drain. */
fun pendingChannelRekeyWraps(): List<Event> = lock.withLock { channelRekeyWraps.values.toList() }
/** 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) }
@@ -580,6 +596,7 @@ class ConcordCommunitySession(
// Control material may already have swapped in an entry carrying the new keys.
if (privateKeySet(newEntry) == derivedPrivateKeys) return false
entry = newEntry
channelRekeyKeys = ConcordPrivateChannels.watchKeys(newEntry)
true
}
// Nothing folded yet: the first control wrap derives the planes from the swapped-in entry.
@@ -646,6 +663,14 @@ class ConcordCommunitySession(
lock.withLock { siblingRekeyWraps[wrap.id] = wrap }
return ConcordIngestOutcome.STRUCTURAL
}
in channelRekeyKeys -> {
// Buffer only, like the base rekeys: opening a blob takes the account signer, and the
// rotator's authority is judged against the fold at drain time.
lock.withLock {
if (channelRekeyWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
}
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
@@ -90,9 +90,14 @@ sealed interface DirectInviteAcceptPlan {
/** A community we don't hold: run the shared join path. */
data object Join : DirectInviteAcceptPlan
/** A held community: store [entry] — the held one plus the newly granted Private Channel keys. */
/**
* A held community: store [entry] — the held one plus the newly granted Private Channel keys
* ([channelIds]). A writer re-applies [channelIds] to the entry it reads inside the List write
* ([ConcordInviteVend.adoptCatchUp] with `only`), never [entry] itself, which is a snapshot.
*/
class CatchUp(
val entry: ConcordCommunityListEntry,
val channelIds: List<HexKey>,
) : DirectInviteAcceptPlan
/** A held community the bundle adds nothing to (or can't: a different base, or dissolved). */
@@ -322,9 +327,11 @@ class ConcordDirectInviteInbox(
* - not held → [DirectInviteAcceptPlan.Join] (the shared join path, which still ban-gates
* against the community's own Control Plane);
* - held on the SAME base with new Private Channel keys → [DirectInviteAcceptPlan.CatchUp],
* the held entry with only those keys merged in — never moving the base (Armada
* `catchUpChannelIds`) — unless the held roster bans [me]; refused while the roster isn't
* folded ([DirectInviteAcceptPlan.RosterNotLoaded]);
* the held entry with only those keys ADDED — never moving the base, never replacing a
* held key (Armada `catchUpChannelIds`) — and only from a sender who is staff in the held
* fold, for channels it knows as live Private Channels
* ([ConcordInviteVend.admissibleCatchUpIds]); refused when the held roster bans [me], and
* while it isn't folded ([DirectInviteAcceptPlan.RosterNotLoaded]);
* - held otherwise (nothing new, a different base, dissolved) → [DirectInviteAcceptPlan.NothingNew].
*/
fun acceptPlan(
@@ -336,12 +343,15 @@ class ConcordDirectInviteInbox(
): DirectInviteAcceptPlan {
if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired
if (held == null) return DirectInviteAcceptPlan.Join
val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite) ?: return DirectInviteAcceptPlan.NothingNew
if (ConcordInviteVend.catchUpChannelIds(held, opened.invite).isEmpty()) return DirectInviteAcceptPlan.NothingNew
if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded
// Death wins every race (CORD-02 §9): a dissolved community takes no new keys.
if (heldState.dissolved) return DirectInviteAcceptPlan.NothingNew
if (heldState.authority.isBanned(me)) return DirectInviteAcceptPlan.Banned
return DirectInviteAcceptPlan.CatchUp(adopted)
val ids = ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender)
if (ids.isEmpty()) return DirectInviteAcceptPlan.NothingNew
val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite, ids) ?: return DirectInviteAcceptPlan.NothingNew
return DirectInviteAcceptPlan.CatchUp(adopted, ids)
}
/**
@@ -368,6 +378,7 @@ class ConcordDirectInviteInbox(
removedAt: Map<String, Long> = emptyMap(),
isFollowed: (HexKey) -> Boolean = { false },
isHidden: (HexKey) -> Boolean = { false },
heldStateOf: (communityId: HexKey) -> ConcordCommunityState? = { null },
): List<ConcordDirectInviteView> {
val nowSecs = nowMs / 1000
val heldById = joined.associateBy { it.id.lowercase() }
@@ -380,7 +391,17 @@ class ConcordDirectInviteInbox(
val buriedAt = removedById[communityId]
if (buriedAt != null && sentAt * 1000 <= buriedAt) continue
val held = heldById[communityId]
val newChannels = ConcordInviteVend.catchUpChannelIds(held, opened.invite)
// For a held community whose fold is in, only what accepting would actually adopt:
// a catch-up from a non-staff sender, for channels the fold doesn't know as Private,
// or into a dissolved community is refused by [acceptPlan], so it is not offered.
val heldState = held?.let { heldStateOf(it.id) }
val newChannels =
when {
held == null -> emptyList()
heldState == null -> ConcordInviteVend.catchUpChannelIds(held, opened.invite)
heldState.dissolved -> emptyList()
else -> ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender)
}
if (held != null && newChannels.isEmpty()) continue
val catchUp = held != null
val base = communityId + "|" + opened.sender.lowercase()
@@ -0,0 +1,237 @@
/*
* 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.ConcordDirectInviteDraft
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.amethyst.commons.model.concord.ConcordIngestOutcome
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.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
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.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Private Channels end to end, headless (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-2): create
* with an access Role, vend on grant through a Direct Invite limited to the gained channel, the
* recipient's click-free adoption, rotate on revoke to the remaining entitled set, and each member's
* receive (the kept adopts, the cut drops the key and records the cut). Plus privatise/publicise.
*/
class ConcordPrivateChannelsTest {
private val owner = NostrSignerInternal(KeyPair())
private val alice = NostrSignerInternal(KeyPair())
private val bob = NostrSignerInternal(KeyPair())
private class World(
val community: NewConcordCommunity,
val editions: MutableList<ControlEdition>,
) {
fun add(wrap: Event) {
editions += ConcordActions.controlEditions(listOf(wrap), community.controlPlane)
}
fun state(): ConcordCommunityState = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey)
}
private suspend fun world(): World {
val c = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
return World(c, ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList())
}
private fun entryOf(
c: NewConcordCommunity,
channels: List<PrivateChannelKey> = emptyList(),
) = ConcordCommunityListEntry(
id = c.communityIdHex,
owner = c.ownerPubKey,
ownerSalt = c.ownerSalt.toHexKey(),
root = c.communityRoot.toHexKey(),
rootEpoch = c.rootEpoch,
controlPk = c.controlPkHex,
privateChannels = channels,
relays = listOf("wss://relay.example"),
name = "Nostrichs",
addedAt = 1_000L,
)
@Test
fun aPrivateChannelIsVendedOnGrantAndRotatedOnRevoke() =
runTest {
val w = world()
val c = w.community
val cid = c.communityId
// 1. Create: an access Role at the bottom of the roster, the channel flagged private, a key at epoch 0.
val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, cid, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L))
build.wraps.forEach { w.add(it) }
val ch = build.channelIdHex
assertEquals(0L, build.key.epoch)
assertTrue(ch in w.state().privateChannelIds)
val role = assertNotNull(w.state().roles[build.roleIdHex])
assertEquals("channel", role.scope?.kind)
assertEquals(ch, role.scope?.channelId)
assertEquals("0", role.permissions)
val ownerEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
// 2. Grant alice and bob the access Role: both gained the channel.
val beforeGrant = w.state().authority
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
val granted = ConcordInviteVend.accessChanges(beforeGrant, w.state().authority, w.state().privateChannelIds).single()
assertEquals(setOf(alice.pubKey, bob.pubKey), granted.gained)
// 3. Vend: a Direct Invite limited to the gained channel, from staff, adopted by alice without a click.
val draft = assertIs<ConcordDirectInviteDraft.Ready>(ConcordActions.draftDirectInvite(ownerEntry, w.state(), owner.pubKey, alice.pubKey, onlyChannelIds = setOf(ch)))
assertEquals(listOf(ch), draft.invite.channels.map { it.id })
val aliceHeld = entryOf(c)
assertEquals(
ConcordInviteVend.CatchUpVerdict.ADOPT,
ConcordInviteVend.judgeCatchUp(w.state().authority, w.state().privateChannelIds, alice.pubKey, owner.pubKey, draft.invite, aliceHeld),
)
val aliceEntry = assertNotNull(ConcordInviteVend.adoptCatchUp(aliceHeld, draft.invite))
val bobEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
assertEquals(build.key.key, ConcordChannelKeyring.heldKey(aliceEntry, ch)?.key)
// 4. Revoke bob: he lost the channel, so the owner rotates it to the remaining entitled set.
val beforeRevoke = w.state().authority
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, emptyList(), w.editions, 4L, owner = c.ownerPubKey))
val revoked = ConcordInviteVend.accessChanges(beforeRevoke, w.state().authority, w.state().privateChannelIds).single()
assertEquals(setOf(bob.pubKey), revoked.lost)
val keep = ConcordPrivateChannels.keepSet(w.state().authority, ch, owner.pubKey)
assertEquals(setOf(owner.pubKey, alice.pubKey), keep)
assertTrue(ConcordPrivateChannels.canRotate(w.state().authority, owner.pubKey, revoked.lost))
// A plain member can't rotate anyone out.
assertFalse(ConcordPrivateChannels.canRotate(w.state().authority, alice.pubKey, setOf(bob.pubKey)))
val newKey = ConcordChannelRekey.mintKey()
val held = assertNotNull(ConcordChannelKeyring.heldKey(ownerEntry, ch))
val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, held, newKey, keep, 5L, authority = null)
// 5. Receive: alice adopts epoch 1, bob is cut and the cut is recorded.
val aliceOut = ConcordPrivateChannels.receive(aliceEntry, wraps, w.editions, w.state().authority, alice)
val adopted = assertIs<ChannelRekeyOutcome.Adopted>(aliceOut[ch])
assertEquals(1L, adopted.epoch)
val aliceNext = assertNotNull(ConcordPrivateChannels.applyOutcome(aliceEntry, aliceOut, mapOf(ch to 0L)))
assertEquals(newKey.toHexKey(), ConcordChannelKeyring.heldKey(aliceNext, ch)?.key)
assertEquals(1L, ConcordChannelKeyring.heldKey(aliceNext, ch)?.epoch)
val bobOut = ConcordPrivateChannels.receive(bobEntry, wraps, w.editions, w.state().authority, bob)
assertEquals(1L, assertIs<ChannelRekeyOutcome.Removed>(bobOut[ch]).epoch)
val bobNext = assertNotNull(ConcordPrivateChannels.applyOutcome(bobEntry, bobOut, mapOf(ch to 0L)))
assertNull(ConcordChannelKeyring.heldKey(bobNext, ch))
assertEquals(mapOf(ch to 1L), ConcordChannelKeyring.cutsOf(bobNext))
// The stale epoch-0 key can't come back through a bundle.
assertTrue(ConcordInviteVend.catchUpChannelIds(bobNext, draft.invite).isEmpty())
// A result computed from a stale epoch never rolls the List back.
assertNull(ConcordPrivateChannels.applyOutcome(aliceNext, aliceOut, mapOf(ch to 0L)))
}
@Test
fun aRotationFromAMemberWithoutAuthorityIsIgnored() =
runTest {
val w = world()
val c = w.community
val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, c.communityId, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L))
build.wraps.forEach { w.add(it) }
w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
val aliceEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
// Bob holds the key but no MANAGE_CHANNELS: holding a key is never authority (CORD-06 §3).
val forged = ConcordPrivateChannels.buildRotation(bob, c.communityRoot, build.key, ConcordChannelRekey.mintKey(), setOf(bob.pubKey), 5L, authority = null)
assertTrue(ConcordPrivateChannels.receive(aliceEntry, forged, w.editions, w.state().authority, alice).isEmpty())
}
@Test
fun aSessionWatchesAndBuffersItsChannelRekeys() =
runTest {
val c = world().community
val chId = ByteArray(32) { 0x5C }
val key = PrivateChannelKey(chId.toHexKey(), "10".repeat(32), 3, "mods")
val entry = entryOf(c, listOf(key))
val session = ConcordCommunitySession(entry, alice.pubKey)
// The next LOOKAHEAD channel epochs past the held one, under the current root.
val next = ConcordChannelRekey.address(c.communityRoot, chId, 4).publicKeyHex
assertTrue(next in session.channelRekeyAddresses())
assertTrue(ConcordChannelRekey.address(c.communityRoot, chId, 3 + ConcordChannelRekey.LOOKAHEAD.toLong()).publicKeyHex in session.channelRekeyAddresses())
assertFalse(ConcordChannelRekey.address(c.communityRoot, chId, 3).publicKeyHex in session.channelRekeyAddresses())
assertTrue(session.ownsPlane(next))
// Also subscribed with the auxiliary planes.
assertTrue(ConcordSubscriptionPlanner.auxiliaryPlaneSubs(listOf(entry)).any { it.pubKeyHex == next })
val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, key, ConcordChannelRekey.mintKey(), setOf(alice.pubKey), 5L, null)
assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(wraps.single()))
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(wraps.single()))
assertEquals(listOf(wraps.single().id), session.pendingChannelRekeyWraps().map { it.id })
}
@Test
fun privatizeClimbsTheChannelEpochAndPublicizeFlipsTheFlagBack() =
runTest {
val w = world()
val c = w.community
val general = c.generalChannelIdHex
val standing = assertNotNull(w.state().channels[general]).definition
assertFalse(standing.private)
val build = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, "insiders", w.editions, w.state().authority, 2L))
build.wraps.forEach { w.add(it) }
// The first privatisation is epoch 1 (CORD-03 §2); a floor seen on the wire lifts it.
assertEquals(1L, build.key.epoch)
assertTrue(general in w.state().privateChannelIds)
assertEquals("insiders", w.state().roles[build.roleIdHex]?.name)
val later = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, null, w.editions, w.state().authority, 2L, observedFloor = 4))
assertEquals(5L, later.key.epoch)
// Already private: nothing to do.
assertNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, w.state().channels[general]!!.definition, null, w.editions, w.state().authority, 2L))
val flip = assertNotNull(ConcordPrivateChannels.publicize(owner, c.controlPlane, c.communityId, general, w.state().channels[general]!!.definition, w.editions, c.ownerPubKey, 3L))
w.add(flip)
assertFalse(general in w.state().privateChannelIds)
// The held private-era key keeps reading its history once the channel is public again.
val heldEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
val planes = ConcordActions.historicalChannelPlanes(heldEntry, general, isPrivate = false)
assertTrue(planes.any { it.epoch == 1L })
// And the next privatisation climbs past it.
assertEquals(2L, ConcordChannelKeyring.nextChannelEpoch(heldEntry, general))
assertTrue(general.hexToByteArray().size == 32)
}
}
@@ -27,10 +27,12 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel
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.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -98,6 +100,13 @@ class ConcordDirectInviteInboxTest {
private fun stateOf(c: NewConcordCommunity): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(c.genesisWraps, c.controlPlane), c.communityId, c.ownerPubKey)
/** [c]'s fold with [vip] defined as a live Private Channel. */
private suspend fun stateWithVip(c: NewConcordCommunity): ConcordCommunityState {
val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList()
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineChannel(owner, c.controlPlane, c.communityId, vip.hexToByteArray(), ChannelEntity(name = "vip", private = true), editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane)
return ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey)
}
@Test
fun aValidWrapIsParkedWithItsVerifiedSenderAndDedupedByWrapId() =
runTest {
@@ -227,6 +236,31 @@ class ConcordDirectInviteInboxTest {
assertEquals(listOf("VIP lounge"), byWrap.getValue(catchUp.wrapId).newChannelNames { if (it == vip) "VIP lounge" else null })
}
@Test
fun aCatchUpThatAcceptWouldRefuseIsNotOffered() =
runTest {
val c = community()
val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList()
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineChannel(owner, c.controlPlane, c.communityId, vip.hexToByteArray(), ChannelEntity(name = "vip", private = true), editions, 2L, owner = c.ownerPubKey)), c.controlPlane)
val state = ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey)
val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip"))
val inbox = ConcordDirectInviteInbox(me)
// A plain member hands over a key: accept refuses it (not staff), so it is not a card.
val fromMember = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant))))
val fromOwner = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = grant))))
val held = listOf(heldEntryOf(c))
val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, held, heldStateOf = { state })
assertEquals(listOf(fromOwner.wrapId), views.map { it.wrapId })
assertIs<DirectInviteAcceptPlan.CatchUp>(ConcordDirectInviteInbox.acceptPlan(fromOwner, held.single(), state, me.pubKey))
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(fromMember, held.single(), state, me.pubKey))
// Before the fold is in, the verdict is unknown: both stay (accept waits for the roster).
assertEquals(2, ConcordDirectInviteInbox.visible(inbox.pending.value.values, held).size)
// A dissolved community takes no keys at all.
assertTrue(ConcordDirectInviteInbox.visible(inbox.pending.value.values, held, heldStateOf = { state.withDissolved(true) }).isEmpty())
}
@Test
fun visibleKeepsOneInvitePerCommunityAndSender() =
runTest {
@@ -389,17 +423,23 @@ class ConcordDirectInviteInboxTest {
runTest {
val c = community()
val held = heldEntryOf(c)
val state = stateOf(c)
val state = stateWithVip(c)
val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip"))
// Same base, new key: a catch-up that keeps the held base and anchor.
val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me))
// Same base, new key, from staff (the owner): a catch-up that keeps the held base and anchor.
val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = grant)), me))
val plan = assertIs<DirectInviteAcceptPlan.CatchUp>(ConcordDirectInviteInbox.acceptPlan(catchUp, held, state, me.pubKey))
assertEquals(held.root, plan.entry.root)
assertEquals(held.rootEpoch, plan.entry.rootEpoch)
assertEquals(held.controlPk, plan.entry.controlPk)
assertEquals("anchor", plan.entry.inviteRef)
assertEquals(listOf(vip), plan.entry.privateChannels.map { it.channelId })
assertEquals(listOf(vip), plan.channelIds)
// A plain keyholder can't plant a key, and a channel the fold doesn't know as Private isn't one.
val fromMember = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me))
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(fromMember, held, state, me.pubKey))
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, held, stateOf(c), me.pubKey))
// No fold yet: the ban verdict is unknown, so it waits.
assertEquals(DirectInviteAcceptPlan.RosterNotLoaded, ConcordDirectInviteInbox.acceptPlan(catchUp, held, null, me.pubKey))
@@ -408,6 +448,11 @@ class ConcordDirectInviteInboxTest {
val holding = held.let { ConcordCommunityListEntry(it.id, it.owner, it.ownerSalt, it.root, it.rootEpoch, it.controlPk, privateChannels = listOf(PrivateChannelKey(vip, "db".repeat(32), 0, "vip")), relays = it.relays, name = it.name) }
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, holding, state, me.pubKey))
// Even from staff, a bundle never REPLACES a held key — not at a higher, nor an absurd, epoch.
// A held key moves only through a channel rekey, whose prevcommit proves continuity.
val hijack = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "ee".repeat(32), 1_000_000_000L, "vip")))), me))
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(hijack, holding, state, me.pubKey))
// A different base for a held community is never adopted, keys or not.
val baseMove = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, root = "99".repeat(32), channels = grant)), me))
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(baseMove, held, state, me.pubKey))
@@ -3631,15 +3631,23 @@
<item quantity="one">%1$d channel</item>
<item quantity="other">%1$d channels</item>
</plurals>
<string name="concord_channel_access_role_label">Access role name</string>
<string name="concord_channel_create">New channel</string>
<string name="concord_channel_delete">Delete channel</string>
<string name="concord_channel_delete_confirm">Delete</string>
<string name="concord_channel_delete_message">Delete #%1$s? This can't be undone and the channel can't be recreated with the same id.</string>
<string name="concord_channel_delete_title">Delete channel?</string>
<string name="concord_channel_make_private">Make private</string>
<string name="concord_channel_make_private_message">#%1$s gets its own key from now on, and the "%2$s" role decides who can read it. Nobody holds that role yet: grant it to the members who should have access. Messages already posted stay readable to everyone in the community.</string>
<string name="concord_channel_make_public">Make public</string>
<string name="concord_channel_make_public_message">Every member of the community will be able to read #%1$s from now on. Messages posted while it was private stay readable only to the members who held its key.</string>
<string name="concord_channel_name_label">Channel name</string>
<string name="concord_channel_no_messages">No messages yet</string>
<string name="concord_channel_private_hint">Only members holding its access role can read it. Grant the role to let them in; revoking it rotates the channel key.</string>
<string name="concord_channel_private_toggle">Private channel</string>
<string name="concord_channel_rename">Rename channel</string>
<string name="concord_channel_rename_save">Rename</string>
<string name="concord_channel_rotate_key">Rotate key</string>
<string name="concord_channels_empty">No channels yet.</string>
<string name="concord_create_action">Create</string>
<string name="concord_create_relays">Relays</string>
@@ -70,7 +70,7 @@ Ranked security > interop > feature inside each group.
| 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) |
| 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 on the winning chain (F7 rotates private channels inside a Refounding under the prior root, which both branches can open, but a losing refounder does not yet re-issue its channel keys after the heal) |
| 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`) |
@@ -87,8 +87,8 @@ Ranked security > interop > feature inside each group.
| 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 | **fixed** — `ConcordInviteRegistry` (builder, strict-array decode, `nextLinks` pruning expired/tombstoned links) + `ConcordCommunityState.inviteRegistries`/`liveInviteLinks`/`isPublic`/`hasForeignLiveLinks`/`banRequiresRefounding`/`retiringWouldPrivatize` (gated on CREATE_INVITE, coordinate bound to author); mint/revoke publish the registry (app + amy); a Private ban Refounds, a Public one is the Banlist alone; retiring the last live link runs a privatizing Refounding (`privatizeConcordCommunity`; amy reports it and adds `refound --privatize`); Public/Private shown in the server view and warned in the revoke dialog. Deviation from Armada, following the spec: a ban Refounds iff the community is Private without the targets' registries (Armada rotates whenever no *foreign* link exists, and only warns on privatizing revokes) |
| F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. Not done: Armada's auto-adopt of staff-sent catch-ups (`judgeCatchUp`) and `channel_cuts` (not modeled here) |
| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | open (depends on S2) |
| F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. F7 follow-ups done: staff-sent catch-ups auto-adopt (`judgeCatchUp`), `channel_cuts` is modeled, and a catch-up now only ADDS a missing key from a staff sender for a live Private Channel (never replaces a held key), re-applied inside the List write |
| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | **fixed** — quartz `ConcordChannelRekey`: root-keyed `concord/rekey-pseudonym` address, 72-byte scope-bound blobs, chunked 3303 with `prevcommit` over the held channel key and `vac` on every chunk; the receive walk adopts only complete, honored (owner / MANAGE_CHANNELS / BAN + synced `vac`) rotations off the held key (multi-epoch, racing rotators → lowest key) and treats "no blob" as a cut only from a rotator who outranks us, published after our join. `ConcordChannelKeyring` rotates keys in place, reads older keys from `seed`/peer `priors` (never writes intermediate keys, CORD-02 §8) and models Armada's `channel_cuts` floor (extension, round-tripped). `ConcordInviteVend.entitledMembers`/`accessChanges`/`judgeCatchUp`. Commons `ConcordPrivateChannels` + app verbs: create Private channel (key at channel epoch 0 + bit-less access Role, as Armada), privatise (next channel epoch, floored by probing the rekey addresses) / publicise, `rekeyConcordChannel`, vend on grant (Direct Invite limited to the gained channels) and rotate on revoke/ban, the Refounding rotates every held private channel under the prior root, sessions subscribe/AUTH/buffer the channel-rekey window and the revision tick drains it; staff catch-ups auto-adopt. Links carry no channel keys (F6's `vendableChannels`, audience link). UI: Private toggle + access-role name on create, Make private/public + Rotate key per channel. `amy concord channel create/privatize/publicize/rekey`, `rekey`/`grant`/`refound` follow channel keys. Not done: republishing a rotated channel's sealed Pin List under the new key (a SHOULD); history across our own rotations after restart (intermediate keys stay out of the List by spec, and the key walk from `seed` is not implemented); a role *scope edit* in the UI does not trigger reconcile (only grants/revokes/bans do) |
| 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 |
@@ -67,6 +67,9 @@ data class ConcordCommunityState(
*/
val inviteRegistries: Map<HexKey, List<HexKey>> = emptyMap(),
) {
/** The ids of the live (non-deleted) Private Channels (CORD-03), lowercase hex. */
val privateChannelIds: Set<HexKey> by lazy { channels.filterValues { it.definition.private }.keys.mapTo(HashSet()) { it.lowercase() } }
/** The aggregate active-set of live public links: every honored registry's link signers (CORD-05 §5). */
val liveInviteLinks: Set<HexKey> by lazy { inviteRegistries.values.flatMapTo(HashSet()) { it } }
@@ -0,0 +1,239 @@
/*
* 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.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordEntryResidue
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.longOrNull
/** A Private Channel key held for an older channel epoch — it still reads its own era's history. */
class HistoricalChannelKey(
val key: HexKey,
val epoch: Long,
)
/**
* The Private Channel keys a Community List entry holds, and how a rotation rewrites them
* (CORD-03 §1-2, CORD-06 §2, CORD-02 §8).
*
* - **Current keys** are the entry's `channels` (`privateChannels`): exactly one per channel, the
* newest epoch held. A rotation replaces it in place, keeping any unknown keys another client
* wrote inside the channel object (the round-trip rule, CORD-02 §6/§8).
* - **Older keys** are never written by this client: CORD-02 §8 keeps intermediate keys out of the
* List ("a client's own optimisation … it does not belong in the List"). They are still *read*
* wherever they already are — the entry's `seed` snapshot (the earliest epoch held, the backfill
* anchor) and the reference client's `priors` extension inside a channel object — so history
* written before a rotation stays readable.
* - **Cuts** are the reference client's `channel_cuts` extension on the entry: per channel, the
* channel epoch whose rotation cut this member out. A floor, never rolled back: a key below it is
* refused, so a stale bundle or catch-up cannot quietly restore revoked access. Kept as the raw
* extension (`[{ "id", "epoch" }]`) with every other field in each object preserved.
*/
object ConcordChannelKeyring {
const val CHANNEL_CUTS = "channel_cuts"
const val PRIORS = "priors"
private val HEX64 = Regex("^[0-9a-fA-F]{64}$")
/** The current key held for [channelIdHex], or null (a keyless listing is not a key). */
fun heldKey(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
): PrivateChannelKey? = entry.privateChannels.firstOrNull { it.channelId.equals(channelIdHex, ignoreCase = true) && HEX64.matches(it.key) }
// ---- cuts ------------------------------------------------------------------
/** The `channel_cuts` floors on [entry], channel id (lowercase) → cut epoch (max wins). */
fun cutsOf(entry: ConcordCommunityListEntry): Map<HexKey, Long> {
val raw = entry.residue.entryExtras[CHANNEL_CUTS] as? JsonArray ?: return emptyMap()
val out = HashMap<HexKey, Long>()
for (element in raw) {
val obj = element as? JsonObject ?: continue
val id = (obj["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() ?: continue
val epoch = (obj["epoch"] as? JsonPrimitive)?.longOrNull ?: continue
if (epoch > (out[id] ?: Long.MIN_VALUE)) out[id] = epoch
}
return out
}
/** True when a key for [channelIdHex] at [epoch] sits below a recorded cut and must be refused. */
fun isCutOff(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
epoch: Long,
): Boolean = cutsOf(entry)[channelIdHex.lowercase()]?.let { epoch < it } ?: false
/**
* [entry] with a cut for [channelIdHex] at [epoch] merged into `channel_cuts` (max wins — a
* removal never rolls back). Other channels' cut objects, and unknown keys inside the replaced
* one, ride through untouched.
*/
fun withCut(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
epoch: Long,
): ConcordCommunityListEntry {
val id = channelIdHex.lowercase()
val existing = (entry.residue.entryExtras[CHANNEL_CUTS] as? JsonArray)?.toList() ?: emptyList()
if ((cutsOf(entry)[id] ?: Long.MIN_VALUE) >= epoch) return entry
val mine = existing.filter { (it as? JsonObject)?.let { o -> (o["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() == id } == true }
val base = (mine.firstOrNull() as? JsonObject) ?: JsonObject(emptyMap())
val next = JsonObject(base + mapOf("id" to JsonPrimitive(id), "epoch" to JsonPrimitive(epoch)))
val cuts = JsonArray(existing.filterNot { it in mine } + next)
val extras = JsonObject(entry.residue.entryExtras + (CHANNEL_CUTS to cuts))
return entry.copyChannels(entry.privateChannels, ConcordEntryResidue(extras, entry.residue.seed, entry.residue.currentExtras))
}
// ---- current keys ----------------------------------------------------------
/**
* [entry] holding [key] as the current key for its channel — replacing a lower epoch, keeping
* the replaced object's unknown fields — or null when it would not move anything forward: a key
* at or below the held epoch, or one below a recorded cut.
*/
fun withChannelKey(
entry: ConcordCommunityListEntry,
key: PrivateChannelKey,
): ConcordCommunityListEntry? {
if (!HEX64.matches(key.key) || !HEX64.matches(key.channelId)) return null
if (isCutOff(entry, key.channelId, key.epoch)) return null
val held = entry.privateChannels.firstOrNull { it.channelId.equals(key.channelId, ignoreCase = true) }
if (held != null && HEX64.matches(held.key) && held.epoch >= key.epoch) return null
val next =
PrivateChannelKey(
channelId = key.channelId.lowercase(),
key = key.key.lowercase(),
epoch = key.epoch,
name = key.name.ifBlank { held?.name ?: "" },
extras = held?.extras ?: key.extras,
)
return entry.copyChannels(entry.privateChannels.filterNot { it.channelId.equals(key.channelId, ignoreCase = true) } + next, entry.residue)
}
/** [entry] with [channelIdHex]'s key moved to [newKeyHex] at [newEpoch] (a rotation it launched or adopted). */
fun withRotatedKey(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
newKeyHex: HexKey,
newEpoch: Long,
): ConcordCommunityListEntry? {
val held = heldKey(entry, channelIdHex) ?: return null
return withChannelKey(entry, PrivateChannelKey(held.channelId, newKeyHex, newEpoch, held.name, held.extras))
}
/**
* [entry] after a rotation to [cutEpoch] cut this member from [channelIdHex] (CORD-06 §2): the
* key leaves `channels` and the cut is recorded, so no older key can come back.
*/
fun withoutChannel(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
cutEpoch: Long,
): ConcordCommunityListEntry {
val dropped = entry.copyChannels(entry.privateChannels.filterNot { it.channelId.equals(channelIdHex, ignoreCase = true) }, entry.residue)
return withCut(dropped, channelIdHex, cutEpoch)
}
// ---- older keys --------------------------------------------------------------
/**
* Every older key this entry still carries for [channelIdHex] — the `seed` snapshot's and any
* `priors` a peer wrote — excluding the current one, newest first. Each reads its own epoch's
* history.
*/
fun historicalKeys(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
): List<HistoricalChannelKey> {
val id = channelIdHex.lowercase()
val current = heldKey(entry, id)
val out = LinkedHashMap<Pair<Long, String>, HistoricalChannelKey>()
fun add(
key: String?,
epoch: Long?,
) {
if (key == null || epoch == null || !HEX64.matches(key)) return
val k = key.lowercase()
if (current != null && current.key.equals(k, ignoreCase = true) && current.epoch == epoch) return
out.getOrPut(epoch to k) { HistoricalChannelKey(k, epoch) }
}
current?.extras?.get(PRIORS)?.let { priors ->
for (p in (priors as? JsonArray).orEmpty()) {
val obj = p as? JsonObject ?: continue
add((obj["key"] as? JsonPrimitive)?.contentOrNull, (obj["epoch"] as? JsonPrimitive)?.longOrNull)
}
}
val seedChannels = entry.residue.seed?.get("channels") as? JsonArray
for (c in seedChannels.orEmpty()) {
val obj = c as? JsonObject ?: continue
if ((obj["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() != id) continue
add((obj["key"] as? JsonPrimitive)?.contentOrNull, (obj["epoch"] as? JsonPrimitive)?.longOrNull)
}
return out.values.sortedByDescending { it.epoch }
}
/**
* The channel epoch a privatisation must mint at (CORD-03 §2): one past the highest generation
* this entry knows of — the held key, any older key, a recorded cut — and [observedFloor] (the
* highest rotation epoch seen on the wire, for a privatiser who never held earlier
* generations). Monotonic, so a stale key is always a lower epoch; 1 for a never-private channel.
*/
fun nextChannelEpoch(
entry: ConcordCommunityListEntry,
channelIdHex: HexKey,
observedFloor: Long = 0,
): Long {
val id = channelIdHex.lowercase()
var highest = observedFloor
entry.privateChannels.filter { it.channelId.equals(id, ignoreCase = true) }.forEach { highest = maxOf(highest, it.epoch) }
historicalKeys(entry, id).forEach { highest = maxOf(highest, it.epoch) }
cutsOf(entry)[id]?.let { highest = maxOf(highest, it) }
return highest + 1
}
private fun ConcordCommunityListEntry.copyChannels(
privateChannels: List<PrivateChannelKey>,
residue: ConcordEntryResidue,
) = 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,
)
}
@@ -84,6 +84,9 @@ data class AuthorityResolver private constructor(
fun isOwner(pubKey: String): Boolean = pubKey.lowercase() == ownerLower
/** The owner's pubkey (lowercase hex), proven by the `community_id` rather than any fold. */
fun owner(): String = ownerLower
fun isBanned(pubKey: String): Boolean = pubKey.lowercase() in banned
/** The role ids a member currently holds (empty for the owner and for plain members). */
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withPrivateChannels
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -80,6 +80,51 @@ object ConcordInviteVend {
return held.filter { it.key.isNotBlank() && isEntitled(authority, memberHex, it.channelId) }
}
/**
* Everyone entitled to [channelIdHex]'s key under [authority]: the owner plus every non-banned
* holder of a Role scoped to the channel. Entitlement needs a Grant, so the roster enumerates it
* exactly — no member census required. This is the keep-set of a channel rotation (CORD-06).
*/
fun entitledMembers(
authority: AuthorityResolver,
channelIdHex: HexKey,
): Set<HexKey> {
val scoped = channelRoleIds(authority, channelIdHex)
val out = HashSet<HexKey>()
out.add(authority.owner())
for (member in authority.roleHolders()) {
if (authority.isBanned(member)) continue
if (authority.rolesOf(member).any { it in scoped }) out.add(member.lowercase())
}
return out
}
/** Who gained and who lost a Private Channel's entitlement between two folds. */
class AccessChange(
val channelIdHex: HexKey,
val gained: Set<HexKey>,
val lost: Set<HexKey>,
)
/**
* How a roster change — a Grant, a revoke, a Role's scope edit or deletion, a ban — moved
* entitlement to each of [channelIds] (Armada `channelsHingingOn`, generalised to any edit):
* the members to vend each channel's key to, and the ones a rotation must now cut. Channels
* nobody gained or lost are omitted.
*/
fun accessChanges(
before: AuthorityResolver,
after: AuthorityResolver,
channelIds: Collection<HexKey>,
): List<AccessChange> =
channelIds.mapNotNull { id ->
val was = entitledMembers(before, id)
val now = entitledMembers(after, id)
val gained = now - was
val lost = was - now
if (gained.isEmpty() && lost.isEmpty()) null else AccessChange(id.lowercase(), gained, lost)
}
/** The [held] keys as bundle channel grants (lowercase hex, as Armada writes them). */
fun toInviteChannels(held: List<PrivateChannelKey>): List<InviteChannel> = held.map { InviteChannel(it.channelId.lowercase(), it.key.lowercase(), it.epoch, it.name) }
@@ -92,6 +137,12 @@ object ConcordInviteVend {
* the member onto attacker-read streams. So it counts only on the SAME `community_root`,
* `root_epoch` and `control_pk` (swapping `control_pk` alone would eclipse the member onto an
* attacker's Control Plane); the base advances only by a CORD-06 rekey.
*
* And it only ever ADDS a channel this member holds no key for. A held key moves forward only
* through a channel rekey (CORD-06 §2), whose `prevcommit` proves it extends the very key held;
* a bare bundle proves nothing, so letting one replace a held key would let any keyholder
* park a member on a dead key at an absurd epoch that every later honest delivery then loses
* to. A key below a recorded cut (the rotation that removed us) never comes back either.
*/
fun catchUpChannelIds(
held: ConcordCommunityListEntry?,
@@ -102,34 +153,109 @@ object ConcordInviteVend {
if (!bundle.communityRoot.equals(held.root, ignoreCase = true)) return emptyList()
if (bundle.rootEpoch != held.rootEpoch) return emptyList()
if (!sameOptionalHex(bundle.controlPk, held.controlPk)) return emptyList()
val heldEpochs = held.privateChannels.filter { it.key.isNotBlank() }.associate { it.channelId.lowercase() to it.epoch }
val heldIds = held.privateChannels.filter { HEX64.matches(it.key) }.mapTo(HashSet()) { it.channelId.lowercase() }
return bundle.channels
.filter { HEX64.matches(it.id) && HEX64.matches(it.key) }
.filter { c ->
val heldEpoch = heldEpochs[c.id.lowercase()]
heldEpoch == null || c.epoch > heldEpoch
}.map { it.id.lowercase() }
.filter { it.id.lowercase() !in heldIds }
.filterNot { ConcordChannelKeyring.isCutOff(held, it.id, it.epoch) }
.map { it.id.lowercase() }
.distinct()
}
/**
* [held] with the Private Channel keys [bundle] newly contributes ([catchUpChannelIds]) merged
* in — a newer epoch replaces the held one — or null when the bundle contributes nothing. The
* base, epoch, control keys and every other field stay exactly as held.
* The subset of [catchUpChannelIds] a catch-up may actually deliver against the held fold: only
* from a [sender] who is staff there (the owner or a Control-writing permission holder,
* CORD-04 §3 — a plain keyholder could otherwise plant a wrong key that blocks the right one),
* and only for channels the fold knows as live Private Channels ([privateChannelIds]). Empty
* when either fails.
*/
fun admissibleCatchUpIds(
held: ConcordCommunityListEntry?,
bundle: CommunityInvite,
authority: AuthorityResolver,
privateChannelIds: Set<HexKey>,
sender: HexKey,
): List<HexKey> {
if (!authority.isStaff(sender)) return emptyList()
val live = privateChannelIds.mapTo(HashSet()) { it.lowercase() }
return catchUpChannelIds(held, bundle).filter { it in live }
}
/**
* [held] with the Private Channel keys [bundle] newly contributes ([catchUpChannelIds], narrowed
* to [only] when given) added, or null when the bundle contributes nothing. A held key is never
* replaced; the base, epoch, control keys and every other field stay exactly as held.
*/
fun adoptCatchUp(
held: ConcordCommunityListEntry,
bundle: CommunityInvite,
only: Collection<HexKey>? = null,
): ConcordCommunityListEntry? {
val newIds = catchUpChannelIds(held, bundle).toSet()
val newIds = catchUpChannelIds(held, bundle).filter { only == null || it in only }.toSet()
if (newIds.isEmpty()) return null
val delivered =
bundle.channels
.filter { it.id.lowercase() in newIds && HEX64.matches(it.key) }
.groupBy { it.id.lowercase() }
.map { (id, grants) -> grants.maxBy { it.epoch }.let { PrivateChannelKey(id, it.key.lowercase(), it.epoch, it.name) } }
val kept = held.privateChannels.filterNot { it.channelId.lowercase() in newIds }
return held.withPrivateChannels(kept + delivered)
// Through the keyring: a replaced key keeps the unknown fields another client wrote in it.
var next = held
for (key in delivered) next = ConcordChannelKeyring.withChannelKey(next, key) ?: next
return if (next === held) null else next
}
/** Why a catch-up Direct Invite may or may not be adopted without a click ([judgeCatchUp]). */
enum class CatchUpVerdict {
/** The Grant was the consent: adopt now. */
ADOPT,
/** No folded roster yet (the Grant's fold lags): wait. */
NO_FOLD,
/** Not a catch-up at all ([catchUpChannelIds] is empty). */
NOTHING_NEW,
/** The recipient is banned (CORD-04 §4). */
BANNED,
/** A plain keyholder sent it; only staff may plant a key automatically. */
SENDER_NOT_STAFF,
/** It carries a channel the recipient's Roles don't entitle them to. */
NOT_ENTITLED,
}
/**
* Whether a parked catch-up invite — a Direct Invite carrying a Private Channel key an existing
* member lacks, which is how a role grant's key arrives (CORD-05 §6) — may be adopted WITHOUT a
* click (Armada `judgeCatchUp`). Consent came from the Grant; this checks the bundle is the
* delivery it prescribes, against the folded [authority]:
* - the [sender] is the owner or staff (CORD-04 §3), so a plain keyholder can't plant a wrong
* key that would block the right one;
* - the [recipient] isn't banned;
* - EVERY newly contributed channel is one the recipient's Roles entitle them to ([isEntitled]).
*
* - every such channel is a live Private Channel in the fold ([privateChannelIds]).
*
* A manual Accept still needs a staff sender and a live Private Channel
* ([admissibleCatchUpIds]); only the entitlement check is waived by the click.
*/
fun judgeCatchUp(
authority: AuthorityResolver?,
privateChannelIds: Set<HexKey>,
recipient: HexKey,
sender: HexKey,
bundle: CommunityInvite,
held: ConcordCommunityListEntry?,
): CatchUpVerdict {
val vended = catchUpChannelIds(held, bundle)
if (vended.isEmpty()) return CatchUpVerdict.NOTHING_NEW
if (authority == null) return CatchUpVerdict.NO_FOLD
if (authority.isBanned(recipient)) return CatchUpVerdict.BANNED
if (!authority.isStaff(sender)) return CatchUpVerdict.SENDER_NOT_STAFF
val live = privateChannelIds.mapTo(HashSet()) { it.lowercase() }
if (vended.any { it !in live || !isEntitled(authority, recipient, it) }) return CatchUpVerdict.NOT_ENTITLED
return CatchUpVerdict.ADOPT
}
private val HEX64 = Regex("^[0-9a-fA-F]{64}$")
@@ -0,0 +1,327 @@
/*
* 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.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
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.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
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
/**
* One Private Channel's rotation as a receiver sees it (CORD-06 §2): the kind-3303 chunks one
* [rotator] published at one ([newEpoch], [prevCommit]) — two Rotators concurrently rekeying the
* same epoch never merge into one set. [complete] once every chunk `1..total` is held; a missing
* chunk is never a removal. [createdAt] is the newest chunk's `created_at` (seconds), which a
* receiver compares against its join time: a rotation predating the join is not an exclusion.
*/
class ChannelRotation(
val rotator: HexKey,
val channelIdHex: HexKey,
val newEpoch: Long,
val prevEpoch: Long,
val prevCommit: HexKey,
val total: Int,
val chunks: Map<Int, List<RekeyBlob>>,
val authority: AuthorityCitation?,
val createdAt: Long,
) {
val complete: Boolean get() = (1..total).all { it in chunks }
/** Every blob across the held chunks. */
fun blobs(): List<RekeyBlob> = chunks.values.flatten()
}
/** A key the receiver stepped off while walking a channel's rotations: it still reads its own era. */
class SteppedChannelKey(
val key: ByteArray,
val epoch: Long,
/** When the rotation that superseded it was published (seconds). */
val retiredAt: Long,
)
/** What a Private Channel's pending rotations mean for the key this account holds (CORD-06 §2). */
sealed class ChannelRekeyOutcome {
/** Nothing to act on (no honored complete rotation past the held epoch, or one we cannot yet verify). */
object None : ChannelRekeyOutcome()
/** Adopt [key] at [epoch]; [steppedOver] are the keys the walk left behind, newest first. */
class Adopted(
val key: ByteArray,
val epoch: Long,
val steppedOver: List<SteppedChannelKey>,
) : ChannelRekeyOutcome()
/**
* A complete, honored rotation to [epoch] that could have carried our blob did not: we were cut
* from the channel. Drop the key and record the cut, so a stale bundle cannot restore it.
*/
class Removed(
val epoch: Long,
) : ChannelRekeyOutcome()
}
/**
* Single-channel Rekeys (CORD-06 §1-2): rotate one Private Channel's independent key to exactly the
* members who should keep reading it, and follow such a rotation as a member.
*
* - **Address.** A channel rotation to `new_epoch` rides `group_key("concord/rekey-pseudonym",
* community_root, channel_id, new_epoch)` — keyed by the *community* root, not the channel key
* (CORD-02 derivation table), so every member can precompute it. A Refounding seals its channel
* rekeys under the **prior** root (CORD-06 §3), so a receiver watches under the root it holds and
* the one before it.
* - **Blob.** 72 bytes, `scope_id[32] ‖ epoch_be[8] ‖ new_key[32]`, with the channel id as the
* scope ([RekeyPayload]); scope and epoch are verified against the tags before adoption.
* - **Continuity.** `prevcommit` is the epoch-key commitment over the channel key being replaced at
* its epoch; a receiver adopts only a rotation off the exact key it holds, walking several
* rotations in one pass when it missed some.
* - **Authority.** A single-channel Rekey needs `MANAGE_CHANNELS`, a Refounding's needs `BAN`, and
* the Rotator must strictly outrank every removed target — so a receiver treats "no blob for me"
* as a removal only from a Rotator that outranks it (Armada `useChannelRekeyWatch`).
*
* Pinned byte-for-byte to the reference client's `lib/rekey.ts` (`encodeWrappedKey`,
* `buildRekeyRumors`, `channelRekeyGroupKey`) and walk (`useChannelRekeyWatch`).
*/
object ConcordChannelRekey {
/**
* How many channel epochs past the held one a member watches. Watching only `held + 1` strands
* anyone who missed a rotation — they'd never learn they were removed. The reference client's
* `CHANNEL_REKEY_LOOKAHEAD`.
*/
const val LOOKAHEAD = 8
/** The rekey address a rotation of [channelId] to [newEpoch] rides, sealed under [sealingRoot]. */
fun address(
sealingRoot: ByteArray,
channelId: ByteArray,
newEpoch: Long,
): GroupKey = ConcordKeyDerivation.channelRekeyAddress(sealingRoot, channelId, newEpoch)
/** The `prevcommit` a rotation off [heldKey] at [heldEpoch] carries (CORD-02 A.5). */
fun prevCommit(
heldEpoch: Long,
heldKey: ByteArray,
): HexKey = ConcordKeyDerivation.epochKeyCommitment(heldEpoch, heldKey).toHexKey()
/** A fresh 32-byte channel key. */
fun mintKey(): ByteArray = RandomInstance.bytes(32)
/**
* The kind-1059 wraps of one channel rotation: the chunked kind-3303 rumors delivering
* [newKey] at `heldEpoch + 1` to [recipients] (the rotator should include itself, or nobody
* could rotate the channel next time), each chunk carrying [authority] (`vac`), sealed
* (encrypted, rotator-signed) at [address] under [sealingRoot].
*/
suspend fun build(
rotatorSigner: NostrSigner,
sealingRoot: ByteArray,
channelId: ByteArray,
heldKey: ByteArray,
heldEpoch: Long,
newKey: ByteArray,
recipients: Collection<HexKey>,
createdAt: Long,
authority: AuthorityCitation? = null,
): List<Event> {
val newEpoch = heldEpoch + 1
val prevCommit = prevCommit(heldEpoch, heldKey)
val blobs =
recipients.map { it.lowercase() }.distinct().map { recipient ->
ConcordRekey.blobForSigner(rotatorSigner, recipient.hexToByteArray(), channelId, newEpoch, newKey)
}
val widest = blobs.size.coerceAtLeast(1)
val envelopeTags = ConcordRekey.tags(channelId, newEpoch, heldEpoch, 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 stream = address(sealingRoot, channelId, newEpoch)
return chunks.mapIndexed { index, chunk ->
val tags = ConcordRekey.tags(channelId, newEpoch, heldEpoch, prevCommit, index + 1, chunks.size, authority)
val rumor = RumorAssembler.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk))
ConcordStreamEnvelope.wrap(rumor, stream, rotatorSigner, encrypted = true, createdAt = createdAt)
}
}
/**
* Opens the kind-1059 [wraps] seen at channel-rekey addresses ([keys], address hex → key) and
* groups the well-formed chunks for [channelIdHex] into [ChannelRotation]s, keyed by
* (rotator, newepoch, prevcommit). Drops: a wrap at no known address, a plaintext seal (a rekey
* seal is encrypted, CORD-02 §5), a non-3303 rumor, a scope other than the channel, a
* non-decimal or 0-based chunk, a malformed `vac`. A rotation whose chunks disagree on
* `prevepoch`, `total` or citation is distrusted whole.
*/
fun rotations(
wraps: Collection<Event>,
keys: Map<HexKey, GroupKey>,
channelIdHex: HexKey,
): List<ChannelRotation> {
val scope = channelIdHex.lowercase()
val groups = LinkedHashMap<String, MutableList<Parsed>>()
for (wrap in wraps.distinctBy { it.id }) {
val key = keys[wrap.pubKey] ?: continue
val opened = ConcordStreamEnvelope.openOrNull(wrap, key) ?: continue
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)?.lowercase() != scope) continue
val newEpoch = strictLong(rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)) ?: continue
val prevEpoch = strictLong(rumor.tags.firstTagValue(ConcordRekey.TAG_PREVEPOCH)) ?: continue
val prevCommit = rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT)?.lowercase() ?: continue
if (!HEX64.matches(prevCommit)) continue
val (index, total) = ConcordRekey.chunkOf(rumor.tags) ?: continue
val vacTag = rumor.tags.firstOrNull { it.isNotEmpty() && it[0] == VacTag.TAG_NAME }
val citation = if (vacTag == null) null else VacTag.parse(vacTag) ?: continue
val rotator = opened.author.lowercase()
groups
.getOrPut("$rotator:$newEpoch:$prevCommit") { ArrayList() }
.add(Parsed(rotator, newEpoch, prevEpoch, prevCommit, index, total, ConcordRekey.decodeContent(rumor.content), citation, rumor.createdAt))
}
return groups.values.mapNotNull { parsed ->
val first = parsed.first()
if (parsed.any { it.prevEpoch != first.prevEpoch || it.total != first.total }) return@mapNotNull null
val citations = parsed.map { p -> p.citation?.let { VacTag.assemble(it).joinToString(",") } }.distinct()
if (citations.size > 1) return@mapNotNull null
ChannelRotation(
rotator = first.rotator,
channelIdHex = scope,
newEpoch = first.newEpoch,
prevEpoch = first.prevEpoch,
prevCommit = first.prevCommit,
total = first.total,
chunks = parsed.groupBy { it.index }.mapValues { (_, same) -> same.first().blobs },
authority = first.citation,
createdAt = parsed.maxOf { it.createdAt },
)
}
}
/**
* Walks [rotations] of the channel whose key this account holds ([heldKey] at [heldEpoch]) and
* decides what to do (Armada `useChannelRekeyWatch`):
*
* - only **complete** rotations past [heldEpoch] from an [honored] Rotator count;
* - epoch by epoch, ascending: a rotation carrying our blob **and** continuing the key we hold
* at that point (`prevepoch`/`prevcommit`) hands us the next key — racing Rotators at one
* epoch converge on the lexicographically lowest key — and the walk moves on from it;
* - a rotation that carries our blob off a key we can't verify is neither adoption nor removal
* (a gap to fetch);
* - a rotation with no blob for us, published at or after [joinedAtSecs] by a Rotator who
* [outranksMe], is the read-cut (removal needs no chain: hiding is local and safe);
* - a key adopted above the newest exclusion is a re-admission; otherwise the exclusion wins.
*
* The blob opens through [recipientSigner] (one NIP-44 decrypt: bunker-friendly).
*/
suspend fun walk(
rotations: List<ChannelRotation>,
channelIdHex: HexKey,
heldKey: ByteArray,
heldEpoch: Long,
recipientSigner: NostrSigner,
joinedAtSecs: Long,
honored: (ChannelRotation) -> Boolean,
outranksMe: (HexKey) -> Boolean,
): ChannelRekeyOutcome {
val channelId = channelIdHex.hexToByteArray()
val byEpoch =
rotations
.filter { it.channelIdHex.equals(channelIdHex, ignoreCase = true) && it.newEpoch > heldEpoch && it.complete && honored(it) }
.groupBy { it.newEpoch }
.toSortedMap()
if (byEpoch.isEmpty()) return ChannelRekeyOutcome.None
var chainEpoch = heldEpoch
var chainKey = heldKey
var adoptedEpoch: Long? = null
var excludedAt: Long? = null
val stepped = ArrayList<SteppedChannelKey>()
for ((epoch, candidates) in byEpoch) {
var keyHere: ByteArray? = null
var publishedHere: Long? = null
var addressedHere = false
for (set in candidates) {
val locator =
ConcordKeyDerivation
.recipientLocator(set.rotator.hexToByteArray(), recipientSigner.pubKey.hexToByteArray(), channelId, epoch)
.toHexKey()
if (set.blobs().none { it.locator == locator }) continue
addressedHere = true
// Only a rotation off the key we hold at this point can hand us the next one.
if (set.prevEpoch != chainEpoch || set.prevCommit != prevCommit(chainEpoch, chainKey)) continue
val payload =
ConcordRekey.findPayloadWithSigner(set.blobs(), recipientSigner, set.rotator.hexToByteArray(), channelId, epoch)
// A channel blob is exactly 72 bytes; a wider (base-form) payload is malformed here.
if (payload == null || payload.newControlPk != null) continue
keyHere = keyHere?.let { if (ConcordRefounding.compareKeys(payload.newKey, it) < 0) payload.newKey else it } ?: payload.newKey
publishedHere = publishedHere?.let { minOf(it, set.createdAt) } ?: set.createdAt
}
val next = keyHere
if (next != null) {
stepped.add(0, SteppedChannelKey(chainKey, chainEpoch, publishedHere ?: 0))
chainEpoch = epoch
chainKey = next
adoptedEpoch = epoch
continue
}
if (addressedHere) continue
if (candidates.any { it.createdAt >= joinedAtSecs && outranksMe(it.rotator) }) excludedAt = epoch
}
val adopted = adoptedEpoch
if (adopted != null && (excludedAt == null || adopted > excludedAt)) {
return ChannelRekeyOutcome.Adopted(chainKey, adopted, stepped)
}
return excludedAt?.let { ChannelRekeyOutcome.Removed(it) } ?: ChannelRekeyOutcome.None
}
private class Parsed(
val rotator: HexKey,
val newEpoch: Long,
val prevEpoch: Long,
val prevCommit: HexKey,
val index: Int,
val total: Int,
val blobs: List<RekeyBlob>,
val citation: AuthorityCitation?,
val createdAt: Long,
)
private val HEX64 = Regex("^[0-9a-f]{64}$")
/** Strict decimal (`0|[1-9][0-9]*`), as the reference client's `isTagDecimal`. */
private fun strictLong(value: String?): Long? {
if (value.isNullOrEmpty() || value.length > 18 || !value.all { it in '0'..'9' }) return null
if (value.length > 1 && value[0] == '0') return null
return value.toLong()
}
}
@@ -0,0 +1,144 @@
/*
* 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.cord02Community.ConcordCommunityList
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The List side of Private Channel keys (CORD-02 §8, CORD-03 §2, CORD-06 §2): rotations replace
* the one current key in place, older keys are read from `seed` / a peer's `priors` but never
* written, and the reference client's `channel_cuts` floor survives a round trip and refuses a
* key below it.
*/
class ConcordChannelKeyringTest {
private val chan = "a1".repeat(32)
private val other = "b2".repeat(32)
private val k0 = "10".repeat(32)
private val k1 = "11".repeat(32)
private val k2 = "12".repeat(32)
private fun entry(channels: List<PrivateChannelKey>) =
ConcordCommunityListEntry(
id = "c0".repeat(32),
owner = "0f".repeat(32),
ownerSalt = "5a".repeat(32),
root = "22".repeat(32),
rootEpoch = 2,
privateChannels = channels,
name = "Test",
addedAt = 1,
)
private fun roundTrip(e: ConcordCommunityListEntry): ConcordCommunityListEntry = ConcordCommunityList.decode(ConcordCommunityList.encode(listOf(e))).single()
@Test
fun aRotationReplacesTheKeyInPlaceKeepingUnknownFields() {
val wire =
"""{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1,"current":{"community_id":"${"c0".repeat(32)}",
"owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}","root_epoch":2,
"channels":[{"id":"$chan","key":"$k0","epoch":0,"name":"mods","tint":"red"}],"relays":[],"name":"Test"}}]}"""
val held = ConcordCommunityList.decode(wire).single()
val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(held, chan, k1, 1))
val ch = rotated.privateChannels.single()
assertEquals(k1, ch.key)
assertEquals(1, ch.epoch)
assertEquals("mods", ch.name)
// Another client's field inside the channel object survives.
val back = roundTrip(rotated).privateChannels.single()
assertEquals(JsonPrimitive("red"), back.extras["tint"])
// Never backward, never sideways.
assertNull(ConcordChannelKeyring.withRotatedKey(rotated, chan, k2, 1))
assertNull(ConcordChannelKeyring.withRotatedKey(rotated, chan, k2, 0))
}
@Test
fun aCutRoundTripsAndRefusesOlderKeys() {
val held = entry(listOf(PrivateChannelKey(chan, k0, 0, "mods"), PrivateChannelKey(other, k1, 3, "vip")))
val cut = ConcordChannelKeyring.withoutChannel(held, chan, 1)
assertEquals(listOf(other), cut.privateChannels.map { it.channelId })
// Written as the reference client's entry-level extension and read back.
val back = roundTrip(cut)
val encoded = ConcordJson.instance.parseToJsonElement(ConcordCommunityList.encode(listOf(cut))).jsonObject
val cuts = encoded["entries"]!!.jsonArray[0].jsonObject["channel_cuts"]!!.jsonArray
assertEquals(listOf(JsonObject(mapOf("id" to JsonPrimitive(chan), "epoch" to JsonPrimitive(1L)))), cuts.toList())
assertEquals(mapOf(chan to 1L), ConcordChannelKeyring.cutsOf(back))
// A stale key below the cut never comes back; one at/above it (a re-grant) does.
assertTrue(ConcordChannelKeyring.isCutOff(back, chan, 0))
assertNull(ConcordChannelKeyring.withChannelKey(back, PrivateChannelKey(chan, k0, 0)))
assertNotNull(ConcordChannelKeyring.withChannelKey(back, PrivateChannelKey(chan, k2, 1)))
// Max wins; a lower cut never rolls it back.
assertEquals(1L, ConcordChannelKeyring.cutsOf(ConcordChannelKeyring.withCut(back, chan, 0))[chan])
assertEquals(4L, ConcordChannelKeyring.cutsOf(ConcordChannelKeyring.withCut(back, chan, 4))[chan])
}
@Test
fun anUnknownFieldInsideACutSurvivesARaise() {
val wire =
"""{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1,"channel_cuts":[{"id":"$chan","epoch":1,"why":"x"},{"id":"$other","epoch":2}],
"current":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}",
"community_root":"${"22".repeat(32)}","root_epoch":2,"channels":[],"relays":[],"name":"Test"}}]}"""
val held = ConcordCommunityList.decode(wire).single()
val raised = roundTrip(ConcordChannelKeyring.withCut(held, chan, 3))
val cuts = raised.residue.entryExtras["channel_cuts"] as JsonArray
val mine = cuts.map { it.jsonObject }.single { (it["id"] as JsonPrimitive).content == chan }
assertEquals(JsonPrimitive("x"), mine["why"])
assertEquals(mapOf(chan to 3L, other to 2L), ConcordChannelKeyring.cutsOf(raised))
}
@Test
fun olderKeysAreReadFromSeedAndPriorsButNeverWritten() {
val wire =
"""{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1,
"seed":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}",
"root_epoch":0,"channels":[{"id":"$chan","key":"$k0","epoch":0,"name":"mods"}],"relays":[],"name":"Test"},
"current":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}",
"root_epoch":2,"channels":[{"id":"$chan","key":"$k2","epoch":2,"name":"mods","priors":[{"key":"$k1","epoch":1,"retired_at":5}]}],"relays":[],"name":"Test"}}]}"""
val held = ConcordCommunityList.decode(wire).single()
assertEquals(listOf(1L to k1, 0L to k0), ConcordChannelKeyring.historicalKeys(held, chan).map { it.epoch to it.key })
// The next privatisation climbs past every generation this entry knows of.
assertEquals(3, ConcordChannelKeyring.nextChannelEpoch(held, chan))
assertEquals(10, ConcordChannelKeyring.nextChannelEpoch(held, chan, observedFloor = 9))
assertEquals(1, ConcordChannelKeyring.nextChannelEpoch(held, other))
// A rotation we launch adds no prior of its own (CORD-02 §8 keeps intermediate keys out of the List).
val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(held, chan, "13".repeat(32), 3))
val priors = rotated.privateChannels.single().extras[ConcordChannelKeyring.PRIORS] as JsonArray
assertEquals(1, priors.size)
assertFalse(ConcordChannelKeyring.historicalKeys(rotated, chan).any { it.key == "13".repeat(32) })
}
}
@@ -22,6 +22,12 @@ package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
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.cord04Roles.ControlFixtures
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@@ -87,15 +93,15 @@ class ConcordInviteVendTest {
}
@Test
fun aNewerEpochOfAHeldChannelReplacesIt() {
val newer = "ee".repeat(32)
val b = bundle(channels = listOf(InviteChannel(chanA, newer, 2, "mods")))
assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(held, b))
val adopted = assertNotNull(ConcordInviteVend.adoptCatchUp(held, b))
assertEquals(listOf(Triple(chanA, newer, 2L)), adopted.privateChannels.map { Triple(it.channelId, it.key, it.epoch) })
// Same or older epoch contributes nothing.
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanA, newer, 1)))).isEmpty())
fun aBundleNeverReplacesAHeldKey() {
// A held key moves only through a channel rekey (prevcommit continuity). A bare bundle at a
// higher — even absurd — epoch contributes nothing, so a keyholder can't park us on a dead key.
val bogus = "ee".repeat(32)
for (epoch in listOf(2L, 1_000_000_000L)) {
val b = bundle(channels = listOf(InviteChannel(chanA, bogus, epoch, "mods")))
assertTrue(ConcordInviteVend.catchUpChannelIds(held, b).isEmpty())
assertNull(ConcordInviteVend.adoptCatchUp(held, b))
}
}
@Test
@@ -114,4 +120,106 @@ class ConcordInviteVendTest {
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanB, "", 0)))).isEmpty())
assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(channels = emptyList())))
}
// ---- entitlement (Armada channelAccess.ts) and catch-up adoption (catchUpAdoption.ts) --------
private val owner = "0f".repeat(32)
private val alice = "a1".repeat(32)
private val bob = "b2".repeat(32)
private val modRole = "71".repeat(32)
private val accessRole = "72".repeat(32)
private fun role(
roleId: String,
json: String,
) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0)
private fun grant(
member: String,
roleIds: List<String>,
version: Long = 0,
prev: ControlEdition? = null,
) = ControlEdition(
ControlEntityKind.GRANT,
ControlFixtures.grantEid(member).hexToByteArray(),
version,
prev?.hash,
null,
"""{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""",
owner,
"grant-$member-$version",
version,
)
private val roles =
listOf(
// A staff role (MANAGE_CHANNELS) with server scope, and a bit-less access role scoped to chanA.
role(modRole, """{"role_id":"$modRole","name":"Mod","position":2,"permissions":"2"}"""),
role(accessRole, """{"role_id":"$accessRole","name":"mods-room","position":10,"permissions":"0","scope":{"kind":"channel","channel_id":"$chanA"}}"""),
)
private fun authority(vararg extra: ControlEdition): AuthorityResolver = ControlFixtures.resolve(roles + extra, owner)
@Test
fun theOwnerAndScopedRoleHoldersAreEntitled() {
val aliceIn = grant(alice, listOf(accessRole))
val bobMod = grant(bob, listOf(modRole))
val a = authority(aliceIn, bobMod)
assertEquals(setOf(owner, alice), ConcordInviteVend.entitledMembers(a, chanA))
assertTrue(ConcordInviteVend.isEntitled(a, owner, chanB))
// A server-scoped staff role grants authority, never read access.
assertTrue(!ConcordInviteVend.isEntitled(a, bob, chanA))
// A link has no recipient and vends nothing; a member gets exactly their channels.
val held = listOf(PrivateChannelKey(chanA, keyA, 1, "mods"), PrivateChannelKey(chanB, keyB, 0, "vip"))
assertTrue(ConcordInviteVend.vendableChannels(held, a, null).isEmpty())
assertEquals(listOf(chanA), ConcordInviteVend.vendableChannels(held, a, alice).map { it.channelId })
assertEquals(listOf(chanA, chanB), ConcordInviteVend.vendableChannels(held, a, owner).map { it.channelId })
}
@Test
fun accessChangesNameWhoToVendAndWhoARotationMustCut() {
val aliceIn = grant(alice, listOf(accessRole))
val before = authority()
val afterGrant = authority(aliceIn)
val granted = ConcordInviteVend.accessChanges(before, afterGrant, listOf(chanA, chanB)).single()
assertEquals(chanA, granted.channelIdHex)
assertEquals(setOf(alice), granted.gained)
assertTrue(granted.lost.isEmpty())
val afterRevoke = authority(aliceIn, grant(alice, emptyList(), version = 1, prev = aliceIn))
val revoked = ConcordInviteVend.accessChanges(afterGrant, afterRevoke, listOf(chanA)).single()
assertEquals(setOf(alice), revoked.lost)
assertTrue(ConcordInviteVend.accessChanges(afterGrant, afterGrant, listOf(chanA)).isEmpty())
}
@Test
fun aStaffSentCatchUpForAnEntitledChannelIsAdoptedWithoutAClick() {
val member = held
val grantedChan = bundle(channels = listOf(InviteChannel(chanB, keyB, 0, "vip")))
val scopedB = role("73".repeat(32), """{"role_id":"${"73".repeat(32)}","name":"vip","position":11,"permissions":"0","scope":{"kind":"channel","channel_id":"$chanB"}}""")
val a = authority(scopedB, grant(alice, listOf("73".repeat(32))), grant(bob, listOf(modRole)))
val live = setOf(chanA, chanB)
assertEquals(ConcordInviteVend.CatchUpVerdict.ADOPT, ConcordInviteVend.judgeCatchUp(a, live, alice, owner, grantedChan, member))
assertEquals(ConcordInviteVend.CatchUpVerdict.ADOPT, ConcordInviteVend.judgeCatchUp(a, live, alice, bob, grantedChan, member))
assertEquals(ConcordInviteVend.CatchUpVerdict.NO_FOLD, ConcordInviteVend.judgeCatchUp(null, live, alice, owner, grantedChan, member))
// A plain keyholder (alice) can't plant a key in bob's list automatically.
assertEquals(ConcordInviteVend.CatchUpVerdict.SENDER_NOT_STAFF, ConcordInviteVend.judgeCatchUp(a, live, bob, alice, grantedChan, member))
// Bob holds no role scoped to chanB.
assertEquals(ConcordInviteVend.CatchUpVerdict.NOT_ENTITLED, ConcordInviteVend.judgeCatchUp(a, live, bob, owner, grantedChan, member))
assertEquals(ConcordInviteVend.CatchUpVerdict.NOTHING_NEW, ConcordInviteVend.judgeCatchUp(a, live, alice, owner, bundle(channels = listOf(InviteChannel(chanA, keyA, 1))), member))
// A channel the fold doesn't know as a live Private Channel is never auto-adopted.
assertEquals(ConcordInviteVend.CatchUpVerdict.NOT_ENTITLED, ConcordInviteVend.judgeCatchUp(a, setOf(chanA), alice, owner, grantedChan, member))
// Manual accept: staff sender and a live Private Channel, entitlement waived by the click.
assertEquals(listOf(chanB), ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, live, owner))
assertTrue(ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, live, alice).isEmpty())
assertTrue(ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, setOf(chanA), owner).isEmpty())
}
@Test
fun aCatchUpNeverRestoresAKeyBelowACut() {
val cut = ConcordChannelKeyring.withoutChannel(held, chanA, 2)
assertTrue(ConcordInviteVend.catchUpChannelIds(cut, bundle(channels = listOf(InviteChannel(chanA, keyA, 1)))).isEmpty())
assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(cut, bundle(channels = listOf(InviteChannel(chanA, keyB, 2)))))
}
}
@@ -0,0 +1,230 @@
/*
* 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.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ConcordLabels
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
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.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip44Encryption.Nip44
import kotlinx.coroutines.test.runTest
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* CORD-06 §1-2 single-channel Rekeys: the channel rekey address, the 72-byte scope-bound blob,
* the `prevcommit` continuity walk, chunk completeness, racing rotators and the removal rule —
* pinned to the reference client's `lib/rekey.ts` / `useChannelRekeyWatch`.
*/
class ConcordChannelRekeyTest {
private val admin = NostrSignerInternal(KeyPair())
private val alice = NostrSignerInternal(KeyPair())
private val bob = NostrSignerInternal(KeyPair())
private val root = ByteArray(32) { 0x21 }
private val channelId = ByteArray(32) { 0x5C }
private val channelHex = channelId.toHexKey()
private val key0 = ByteArray(32) { 0x10 }
private val now = 1_700_000_000L
private fun keysFor(vararg epochs: Long): Map<HexKey, GroupKey> = epochs.associate { e -> ConcordChannelRekey.address(root, channelId, e).let { it.publicKeyHex to it } }
private suspend fun rotate(
heldKey: ByteArray,
heldEpoch: Long,
newKey: ByteArray,
recipients: List<HexKey>,
rotator: NostrSignerInternal = admin,
createdAt: Long = now,
): List<Event> = ConcordChannelRekey.build(rotator, root, channelId, heldKey, heldEpoch, newKey, recipients + rotator.pubKey, createdAt)
private suspend fun walk(
wraps: List<Event>,
me: NostrSignerInternal,
heldKey: ByteArray = key0,
heldEpoch: Long = 0,
joinedAt: Long = 0,
honored: (ChannelRotation) -> Boolean = { true },
outranksMe: (HexKey) -> Boolean = { true },
): ChannelRekeyOutcome {
val keys = keysFor(*(heldEpoch + 1..heldEpoch + ConcordChannelRekey.LOOKAHEAD).toList().toLongArray())
return ConcordChannelRekey.walk(ConcordChannelRekey.rotations(wraps, keys, channelHex), channelHex, heldKey, heldEpoch, me, joinedAt, honored, outranksMe)
}
@Test
fun theChannelRekeyAddressIsTheRootKeyedRekeyPseudonym() {
// CORD-02 derivation table: concord/rekey-pseudonym, prior community_root, channel_id, new_epoch.
val address = ConcordChannelRekey.address(root, channelId, 3)
assertEquals(ConcordKeyDerivation.groupKey(ConcordLabels.REKEY_PSEUDONYM, root, channelId, 3).publicKeyHex, address.publicKeyHex)
// Keyed by the ROOT, never the channel key: every member can precompute it.
assertNotEquals(ConcordKeyDerivation.groupKey(ConcordLabels.REKEY_PSEUDONYM, key0, channelId, 3).publicKeyHex, address.publicKeyHex)
// Distinct per epoch and from the base-rotation address.
assertNotEquals(address.publicKeyHex, ConcordChannelRekey.address(root, channelId, 4).publicKeyHex)
assertNotEquals(address.publicKeyHex, ConcordKeyDerivation.baseRekeyAddress(root, channelId, 3).publicKeyHex)
}
@OptIn(ExperimentalEncodingApi::class)
@Test
fun aChannelRotationCarriesTheSpecTagsAnd72ByteScopeBoundBlobs() =
runTest {
val newKey = ByteArray(32) { 0x77 }
val wraps = rotate(key0, 0, newKey, listOf(alice.pubKey))
assertEquals(1, wraps.size)
val opened = assertNotNull(ConcordStreamEnvelope.openOrNull(wraps.single(), ConcordChannelRekey.address(root, channelId, 1)))
// A rekey seal is encrypted; the seal names the rotator.
assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, opened.sealKind)
assertEquals(admin.pubKey, opened.author)
val rumor = opened.rumor
assertEquals(ConcordRekey.KIND, rumor.kind)
// ["scope", channel_id] ["newepoch", held+1] ["prevepoch", held] ["prevcommit", A.5 over the held key] ["chunk","1","1"]
assertEquals(
listOf(
listOf("scope", channelHex),
listOf("newepoch", "1"),
listOf("prevepoch", "0"),
listOf("prevcommit", ConcordKeyDerivation.epochKeyCommitment(0, key0).toHexKey()),
listOf("chunk", "1", "1"),
),
rumor.tags.map { it.toList() },
)
// Alice's blob opens under the admin<->alice pairwise key to exactly scope ‖ epoch_be ‖ key.
val blobs = ConcordRekey.decodeContent(rumor.content)
assertEquals(2, blobs.size)
val locator = ConcordKeyDerivation.recipientLocator(admin.pubKey.hexToByteArray(), alice.pubKey.hexToByteArray(), channelId, 1).toHexKey()
val mine = blobs.single { it.locator == locator }
val plain = Base64.Default.decode(Nip44.v2.decrypt(mine.wrapped, Nip44.v2.getConversationKey(alice.keyPair.privKey!!, admin.pubKey.hexToByteArray())))
assertEquals(72, plain.size)
assertContentEquals(channelId, plain.copyOfRange(0, 32))
assertContentEquals(byteArrayOf(0, 0, 0, 0, 0, 0, 0, 1), plain.copyOfRange(32, 40))
assertContentEquals(newKey, plain.copyOfRange(40, 72))
}
@Test
fun aKeptMemberAdoptsTheNewKeyAndARemovedOneIsCut() =
runTest {
val newKey = ByteArray(32) { 0x42 }
val wraps = rotate(key0, 0, newKey, listOf(alice.pubKey))
val kept = assertIs<ChannelRekeyOutcome.Adopted>(walk(wraps, alice))
assertContentEquals(newKey, kept.key)
assertEquals(1, kept.epoch)
assertEquals(1, kept.steppedOver.size)
assertContentEquals(key0, kept.steppedOver.single().key)
val cut = assertIs<ChannelRekeyOutcome.Removed>(walk(wraps, bob))
assertEquals(1, cut.epoch)
}
@Test
fun noBlobIsARemovalOnlyFromAnOutrankingRotatorAfterTheJoin() =
runTest {
val wraps = rotate(key0, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey))
// A rotator that does not strictly outrank us cannot cut us (CORD-06 Authority).
assertSame(ChannelRekeyOutcome.None, walk(wraps, bob, outranksMe = { false }))
// A rotation that predates our join is history, not an exclusion.
assertSame(ChannelRekeyOutcome.None, walk(wraps, bob, joinedAt = now + 1))
// An unauthorized rotator is ignored entirely.
assertSame(ChannelRekeyOutcome.None, walk(wraps, alice, honored = { false }))
}
@Test
fun aRotationOffAKeyWeDoNotHoldIsNeitherAdoptedNorARemoval() =
runTest {
// The rotator claims to extend a different key at our epoch: a fork, never adopted.
val forged = rotate(ByteArray(32) { 0x66 }, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey))
assertSame(ChannelRekeyOutcome.None, walk(forged, alice))
}
@Test
fun aMissedRotationIsWalkedInOnePass() =
runTest {
val key1 = ByteArray(32) { 0x31 }
val key2 = ByteArray(32) { 0x32 }
val wraps = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, listOf(alice.pubKey), createdAt = now + 10)
val adopted = assertIs<ChannelRekeyOutcome.Adopted>(walk(wraps, alice))
assertContentEquals(key2, adopted.key)
assertEquals(2, adopted.epoch)
assertEquals(listOf(1L, 0L), adopted.steppedOver.map { it.epoch })
}
@Test
fun aReadmissionAboveTheCutWinsAndACutAboveTheKeyWins() =
runTest {
val key1 = ByteArray(32) { 0x31 }
val key2 = ByteArray(32) { 0x32 }
// Cut at 1, re-admitted at 2 — but 2 extends key1, which bob never got: no adoption, cut stands.
val wraps = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, listOf(alice.pubKey, bob.pubKey))
assertIs<ChannelRekeyOutcome.Removed>(walk(wraps, bob))
// Alice kept through 1 and then cut at 2: the cut above her key wins.
val cutLater = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, emptyList())
assertEquals(2, assertIs<ChannelRekeyOutcome.Removed>(walk(cutLater, alice)).epoch)
}
@Test
fun racingRotatorsConvergeOnTheLowestKey() =
runTest {
val low = ByteArray(32) { 0x01 }
val high = ByteArray(32) { 0x7F }
val other = NostrSignerInternal(KeyPair())
val wraps = rotate(key0, 0, high, listOf(alice.pubKey)) + rotate(key0, 0, low, listOf(alice.pubKey), rotator = other)
val rotations = ConcordChannelRekey.rotations(wraps, keysFor(1), channelHex)
// Two Rotators at one epoch never merge into one set.
assertEquals(2, rotations.size)
assertContentEquals(low, assertIs<ChannelRekeyOutcome.Adopted>(walk(wraps, alice)).key)
}
@Test
fun anIncompleteRotationIsNeverARemoval() =
runTest {
// 130 recipients span two chunks; drop the second.
val crowd = List(130) { KeyPair().pubKey.toHexKey() }
val wraps = rotate(key0, 0, ByteArray(32) { 0x42 }, crowd)
assertEquals(2, wraps.size)
val rotations = ConcordChannelRekey.rotations(wraps.take(1), keysFor(1), channelHex)
assertTrue(rotations.none { it.complete })
assertSame(ChannelRekeyOutcome.None, walk(wraps.take(1), bob))
assertIs<ChannelRekeyOutcome.Removed>(walk(wraps, bob))
}
@Test
fun aRotationForAnotherChannelOrAtAnUnwatchedAddressIsIgnored() =
runTest {
val otherChannel = ByteArray(32) { 0x5D }
val elsewhere = ConcordChannelRekey.build(admin, root, otherChannel, key0, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey), now)
// Not at our channel's addresses, and its scope names another channel.
assertTrue(ConcordChannelRekey.rotations(elsewhere, keysFor(1), channelHex).isEmpty())
val atOurAddress = mapOf(ConcordChannelRekey.address(root, otherChannel, 1).let { it.publicKeyHex to it })
assertTrue(ConcordChannelRekey.rotations(elsewhere, atOurAddress, channelHex).isEmpty())
}
}