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

# Conflicts:
#	cli/README.md
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt
#	quartz/plans/2026-09-29-concord-spec-conformance.md
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt
This commit is contained in:
Claude
2026-09-29 15:48:48 +00:00
26 changed files with 2077 additions and 385 deletions
+5 -2
View File
@@ -679,9 +679,12 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List
| `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 channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). |
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. | | `amy concord 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 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. | | `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator). |
| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. | | `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. |
| `amy concord join URL` | Redeem an invite link and save the community. | | `amy concord join URL` | Redeem an invite link, save the community, and publish a Guestbook Join echoing the link's attribution (CORD-05 §1/§6). |
| `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). |
| `amy concord recover [COMMUNITY] [--rejoin]` | Report whether a Refounding left us behind (our joined-through link resolves to a higher epoch). A bundle never moves the base on its own (CORD-06 §2); `--rejoin` explicitly re-accepts the link. Ban-gated, fails closed. |
| `amy concord refound COMMUNITY --remove U[,U…]` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after; reserves its keys so a re-run resumes with the same root; refused for a dissolved community. |
| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). | | `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). |
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`; also `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `MANAGE_MESSAGES`, `CREATE_INVITE`, `VIEW_AUDIT_LOG`, `MENTION_EVERYONE`, `PIN_MESSAGES`). | | `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. |
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
@@ -46,6 +47,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -79,9 +81,11 @@ object ConcordCommands {
| concord join URL redeem an invite link and save the community | concord join URL redeem an invite link and save the community
| concord rekey [COMMUNITY] follow a Refounding we were re-keyed for: | 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
| concord recover [COMMUNITY] re-resolve the joined-through invite link and | concord recover [COMMUNITY] [--rejoin] re-resolve the joined-through invite link and
| follow a Refounding we were left out of | report whether a Refounding left us behind;
| (CORD-06); refuses if that epoch banned us | --rejoin re-accepts that link (a bundle never
| moves the base on its own, CORD-06 §2);
| refuses if that epoch banned us
| concord roles COMMUNITY list live roles + current banlist (CORD-04) | concord roles COMMUNITY list live roles + current banlist (CORD-04)
| concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK) | concord 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
@@ -285,7 +289,9 @@ object ConcordCommands {
ctx.prepare() ctx.prepare()
// The joiner cannot derive the Control Plane address, so the invite carries it // The joiner cannot derive the Control Plane address, so the invite carries it
// (CORD-05 §1); omitted for a legacy community, which has none to carry. // (CORD-05 §1); omitted for a legacy community, which has none to carry.
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null }) // The creator rides in the bundle so joiners echo it in their Guestbook Join (CORD-05 §1).
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null }, creator = ctx.signer.pubKey)
// At most 3 bootstrap relays ride in the fragment (CORD-05 §3); the codec truncates.
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays) val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays)
// Record the link BEFORE publishing the bundle (CORD-05, kind 13303): a link whose // Record the link BEFORE publishing the bundle (CORD-05, kind 13303): a link whose
// `signer_sk` was never stored can never be refreshed, so the next Refounding orphans // `signer_sk` was never stored can never be refreshed, so the next Refounding orphans
@@ -433,7 +439,7 @@ object ConcordCommands {
// relay that kept the old version hand out a link its creator revoked — and it cannot // relay that kept the old version hand out a link its creator revoked — and it cannot
// tell the user which of "revoked", "expired" or "gone" they are looking at. // tell the user which of "revoked", "expired" or "gone" they are looking at.
val bundle = val bundle =
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { when (val status = ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token)) {
is InviteBundleStatus.Live -> status.invite is InviteBundleStatus.Live -> status.invite
is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined") is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined")
InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator") InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator")
@@ -469,7 +475,7 @@ object ConcordCommands {
return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)") return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)")
} }
ConcordStore(dataDir.concordFile).upsert( val stored =
StoredCommunity( StoredCommunity(
name = bundle.name, name = bundle.name,
communityId = bundle.communityId, communityId = bundle.communityId,
@@ -485,15 +491,53 @@ object ConcordCommands {
// this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic.
inviteRef = ConcordActions.bareInviteRef(url) ?: "", inviteRef = ConcordActions.bareInviteRef(url) ?: "",
privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
), )
) ConcordStore(dataDir.concordFile).upsert(stored)
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays))
// Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later
// Refounding finds this member to re-key, and it echoes the link's attribution so link
// holders can count per-link joins. Best-effort, like every Guestbook motion.
val announced = announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label)
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced))
return 0 return 0
} }
} }
// ---- shared helpers (used by ConcordChannelCommands too) ------------------ // ---- shared helpers (used by ConcordChannelCommands too) ------------------
private val HEX64 = Regex("^[0-9a-f]{64}$")
/** Publishes a Guestbook Join for [sc] at its current epoch, echoing invite attribution; true if a relay took it. */
suspend fun announceGuestbookJoin(
ctx: Context,
sc: StoredCommunity,
inviteCreator: String?,
inviteLabel: String?,
): Boolean {
val creator = inviteCreator?.lowercase()?.takeIf { HEX64.matches(it) }
val label = inviteLabel?.takeIf { creator != null && it.isNotBlank() }
val guestbook = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
val wrap = ConcordActions.buildGuestbookJoin(ctx.signer, guestbook, TimeUtils.now(), creator, label)
val relays = relaysFor(ctx, sc)
ctx.registerConcordStreamKeys(relays, listOf(guestbook.secretKey))
return ctx.publish(wrap, relays).values.any { it.accepted }
}
/**
* Whether [sc] carries a valid owner tombstone (CORD-02 §9). Death wins every race: no rekey,
* recovery or Refounding moves a dissolved community forward.
*/
suspend fun isDissolved(
ctx: Context,
sc: StoredCommunity,
): Boolean {
val grave = ConcordDissolution.planeKey(sc.communityId)
val relays = relaysFor(ctx, sc)
ctx.registerConcordStreamKeys(relays, listOf(grave.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(grave.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
return ConcordDissolution.isDissolved(wraps, sc.communityId, sc.owner)
}
fun parseRelays(csv: String?): List<String> = csv?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() fun parseRelays(csv: String?): List<String> = csv?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
fun normalize(urls: List<String>): Set<NormalizedRelayUrl> = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() fun normalize(urls: List<String>): Set<NormalizedRelayUrl> = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
@@ -577,23 +621,21 @@ object ConcordCommands {
) )
/** /**
* `concord recover [COMMUNITY]` — the stranded-recovery receive path (CORD-05/06 A2). * `concord recover [COMMUNITY] [--rejoin]` — stranded detection (CORD-05/06).
* *
* A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a * A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a
* member simply left out of the rekey receives nothing and sits on the dead epoch forever while * member simply left out of the rekey receives nothing and sits on the dead epoch while
* everyone else moves on. There is no message to miss, which is why the rekey drain cannot help. * everyone else moves on. The invite link the membership was joined through is re-minted at
* The way back is the invite link the membership was joined through: the community keeps * the current epoch, so a live bundle there at a **strictly higher** epoch says we were left
* re-minting its bundle at the same addressable coordinate, so a live bundle at a **strictly * behind.
* higher** epoch than ours proves we were left behind — and carries the new root.
* *
* Amethyst sweeps this on a timer; amy makes it an explicit verb, so it stays deterministic and * A bundle is NOT proof of continuity (nothing binds `community_root` to `community_id`), so
* scriptable rather than a background loop. * this verb only reports by default — a link creator must not be able to relocate everyone who
* joined through their link (CORD-06 §2: the base moves only by a verifiable rekey).
* `--rejoin` is the user explicitly re-accepting that link: the same trust decision as `join`.
* *
* The ban gate is the point of care. A removed member keeps the link's unlock token forever, so * Ban-gated at the epoch we are leaving and **fails closed** (no fold, no verdict, no rejoin);
* without it this walks them straight back into the epoch they were rotated out of. It reads the * a dissolved community is never moved (CORD-02 §9).
* banlist of the epoch we are **leaving** (the last Control Plane we can still fold) and **fails
* closed**: a community whose plane will not fold yields no verdict and is skipped, never
* recovered.
*/ */
private suspend fun recover( private suspend fun recover(
dataDir: DataDir, dataDir: DataDir,
@@ -601,6 +643,7 @@ object ConcordCommands {
): Int { ): Int {
val args = Args(rest) val args = Args(rest)
val handle = args.positionalOrNull(0) val handle = args.positionalOrNull(0)
val rejoin = args.bool("rejoin")
args.rejectUnknown() args.rejectUnknown()
val store = ConcordStore(dataDir.concordFile) val store = ConcordStore(dataDir.concordFile)
val targets = val targets =
@@ -614,54 +657,68 @@ object ConcordCommands {
ctx.prepare() ctx.prepare()
val results = mutableListOf<Map<String, Any?>>() val results = mutableListOf<Map<String, Any?>>()
for (sc in targets) { for (sc in targets) {
fun skip(reason: String) = mapOf("community_id" to sc.communityId, "name" to sc.name, "stranded" to false, "recovered" to false, "reason" to reason)
val inviteRef = sc.inviteRef.ifBlank { null } val inviteRef = sc.inviteRef.ifBlank { null }
if (inviteRef == null) { if (inviteRef == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_invite_ref") results += skip("no_invite_ref")
continue continue
} }
val parsed = ConcordActions.parseInviteLink(inviteRef) val parsed = ConcordActions.parseInviteLink(inviteRef)
if (parsed == null) { if (parsed == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "bad_invite_ref") results += skip("bad_invite_ref")
continue
}
if (isDissolved(ctx, sc)) {
results += skip("dissolved")
continue continue
} }
val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() } val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() }
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second } val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second }
// Only a LIVE bundle recovers: an expired or revoked link is not a rotation we missed. // Only a LIVE bundle counts: an expired or revoked link is not a rotation we missed.
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite val bundle = (ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite
if (bundle == null) { if (bundle == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_live_bundle") results += skip("no_live_bundle")
continue continue
} }
// Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict, // Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict.
// no recovery — the gate fails closed rather than assuming "not banned".
val cp = controlPlaneKeysFor(sc) val cp = controlPlaneKeysFor(sc)
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
val editions = ConcordActions.controlEditions(controlWraps, cp) val editions = ConcordActions.controlEditions(controlWraps, cp)
if (editions.isEmpty()) { if (editions.isEmpty()) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "control_plane_not_folded") results += skip("control_plane_not_folded")
continue continue
} }
val bannedHere = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner).isBanned(ctx.signer.pubKey) val bannedHere = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner).isBanned(ctx.signer.pubKey)
val entry = entryFor(sc)
val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere) if (!ConcordActions.isStranded(entry, bundle, bannedHere)) {
if (merged == null) { results += skip(if (bannedHere) "banned" else "already_current") + ("root_epoch" to sc.rootEpoch)
continue
}
if (!rejoin) {
results += results +=
mapOf( mapOf(
"community_id" to sc.communityId, "community_id" to sc.communityId,
"name" to sc.name, "name" to sc.name,
"stranded" to true,
"recovered" to false, "recovered" to false,
"reason" to if (bannedHere) "banned" else "already_current", "reason" to "rejoin_required",
"root_epoch" to sc.rootEpoch, "root_epoch" to sc.rootEpoch,
"link_epoch" to bundle.rootEpoch,
) )
continue continue
} }
store.upsert(storedFrom(sc, merged)) val merged = ConcordActions.rejoinStranded(entry, bundle, bannedHere) ?: continue
val stored = storedFrom(sc, merged)
store.upsert(stored)
announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label)
results += results +=
mapOf( mapOf(
"community_id" to sc.communityId, "community_id" to sc.communityId,
"name" to sc.name, "name" to sc.name,
"stranded" to true,
"recovered" to true, "recovered" to true,
"from_epoch" to sc.rootEpoch, "from_epoch" to sc.rootEpoch,
"root_epoch" to merged.rootEpoch, "root_epoch" to merged.rootEpoch,
@@ -681,8 +738,11 @@ object ConcordCommands {
* strands every other CLI member even though their blob is sitting on the relay. * strands every other CLI member even though their blob is sitting on the relay.
* *
* The rotator is authorized against the roster of the epoch being **left** — `hasPermission`, * The rotator is authorized against the roster of the epoch being **left** — `hasPermission`,
* never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed: * never `effectivePermissions`, so a banned BAN-holder cannot rotate us — and must cite the
* a plane that will not fold yields no verdict and the community is skipped. * Grant it acts under (`vac`, CORD-06 §3) at a version that fold has synced; the owner cites
* nothing. Racing honored rotations converge on the lowest new root. Fails closed: a plane that
* will not fold yields no verdict and the community is skipped. A dissolved community is never
* moved (CORD-02 §9).
*/ */
private suspend fun rekey( private suspend fun rekey(
dataDir: DataDir, dataDir: DataDir,
@@ -698,21 +758,12 @@ object ConcordCommands {
ctx.prepare() ctx.prepare()
val results = mutableListOf<Map<String, Any?>>() val results = mutableListOf<Map<String, Any?>>()
for (sc in targets) { for (sc in targets) {
if (isDissolved(ctx, sc)) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "dissolved", "root_epoch" to sc.rootEpoch)
continue
}
val relays = relaysFor(ctx, sc) val relays = relaysFor(ctx, sc)
val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) // Authorize against the epoch we are LEAVING — the last plane we can fold.
ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
val received =
ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch)
if (received == null) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch)
continue
}
if (received.newEpoch <= sc.rootEpoch) {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch)
continue
}
// Authorize the rotator against the epoch we are LEAVING — the last plane we can fold.
val cp = controlPlaneKeysFor(sc) val cp = controlPlaneKeysFor(sc)
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
@@ -721,12 +772,28 @@ object ConcordCommands {
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded") results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded")
continue continue
} }
if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner), received.rotator)) { val entry = entryFor(sc)
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator) val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
val honored = { r: ReceivedRefounding -> ConcordReceive.isHonoredRotation(entry, editions, authority, r) }
val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
val received = ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch, accept = honored)
if (received == null) {
// Distinguish "no blob at all" from "only rotations we refuse to honor".
val any = ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch)
val reason = if (any == null) "no_blob_for_us" else "unauthorized_rotator"
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to reason, "root_epoch" to sc.rootEpoch) + (if (any != null) mapOf("rotator" to any.rotator) else emptyMap())
continue continue
} }
val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) if (received.newEpoch <= sc.rootEpoch) {
store.upsert(storedFrom(sc, adopted)) results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch)
continue
}
val adopted = ConcordReceive.withAdoptedRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
// A rotation we adopted supersedes any Refounding of ours still reserved.
store.upsert(storedFrom(sc, adopted).copy(pendingRefounding = null))
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator) 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)) Output.emit(mapOf("communities" to results))
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity 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.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
@@ -36,6 +37,9 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -286,6 +290,11 @@ object ConcordModCommands {
.toSet() .toSet()
if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user") if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user")
// Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored.
if (ConcordCommands.isDissolved(ctx, sc)) {
return Output.error("dissolved", "community '$handle' has been dissolved; a Refounding cannot cross the tombstone (CORD-02 §9)")
}
val loaded = load(ctx, sc, dataDir) val loaded = load(ctx, sc, dataDir)
val (cp, editions) = loaded val (cp, editions) = loaded
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner) val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
@@ -306,8 +315,22 @@ object ConcordModCommands {
// split epoch it takes the current control_root (CORD-02 §2). // split epoch it takes the current control_root (CORD-02 §2).
writeGuard(cp)?.let { return it } writeGuard(cp)?.let { return it }
// The rotation cites the Grant it acts under (CORD-06 §3 "Authority"), or no receiver
// honors it; the owner cites nothing.
val citation = ConcordReceive.rotationCitation(ConcordCommands.entryFor(loaded.community), editions, me)
if (citation == null && !authority.isOwner(me)) {
return Output.error("forbidden", "no Grant of yours in this community's fold to cite; receivers would drop the rotation (CORD-06 §3)")
}
val relays = ConcordCommands.relaysFor(ctx, sc) val relays = ConcordCommands.relaysFor(ctx, sc)
// 0. Acquire the WHOLE plane before the first publish (CORD-06 §3: a Refounder that cannot
// fold every Control event must abort). Paged to completion, not a single capped REQ.
val swept = ctx.drainAllPages(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }).map { it.second }
if (swept.isEmpty()) {
return Output.error("control_plane_unreadable", "could not page this community's Control Plane; refusing to compact a partial plane (CORD-06 §3)")
}
// 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the // 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the
// new epoch — carries the ban. Each edition chains onto the updated banlist head. // new epoch — carries the ban. Each edition chains onto the updated banlist head.
var chain = editions var chain = editions
@@ -334,45 +357,67 @@ object ConcordModCommands {
// 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff // 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff
// get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one). // get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one).
val newRoot = RandomInstance.bytes(32) // The keys are RESERVED and persisted before anything is published, so a retried
val newControlRoot = RandomInstance.bytes(32) // `refound` re-delivers the same root instead of minting a sibling (CORD-06 §3).
// Compact from what we KNOW the plane holds: the wraps we drained plus the bans we just val priorRoot = sc.root.hexToByteArray()
val keys =
ConcordRefounding.reserveKeys(
loaded.community.pendingRefounding?.let { PendingRefounding(sc.communityId, it.rootEpoch, it.prevCommit, it.newRoot.hexToByteArray(), it.newControlRoot.hexToByteArray()) },
sc.communityId,
sc.rootEpoch,
priorRoot,
)
val reserved = loaded.community.copy(pendingRefounding = StoredPendingRefounding(keys.rootEpoch, keys.prevCommit, keys.newRoot.toHexKey(), keys.newControlRoot.toHexKey()))
ConcordStore(dataDir.concordFile).upsert(reserved)
// Compact from what we KNOW the plane holds: the paged sweep plus the bans we just
// published. Re-draining alone would race the relay's indexing, and a relay that has not // published. Re-draining alone would race the relay's indexing, and a relay that has not
// yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch // yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch
// whose roster never banned the member we are removing. // whose roster never banned the member we are removing.
val drained = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } val controlWraps = (swept + banWraps).distinctBy { it.id }
val controlWraps = (drained + banWraps).distinctBy { it.id }
val build = val build =
ConcordActions.buildRefounding( try {
rotatorSigner = ctx.signer, ConcordActions.buildRefounding(
communityId = sc.communityId, rotatorSigner = ctx.signer,
priorRoot = sc.root.hexToByteArray(), communityId = sc.communityId,
newRoot = newRoot, priorRoot = priorRoot,
newControlRoot = newControlRoot, newRoot = keys.newRoot,
rootEpoch = sc.rootEpoch, newControlRoot = keys.newControlRoot,
priorControlWraps = controlWraps, rootEpoch = sc.rootEpoch,
priorControlKeys = cp, priorControlWraps = controlWraps,
recipientsXOnly = recipients, priorControlKeys = cp,
staffXOnly = authority.staffMembers(), recipientsXOnly = recipients,
createdAt = TimeUtils.now(), staffXOnly = authority.staffMembers(),
ownerPubKey = sc.owner, createdAt = TimeUtils.now(),
) ownerPubKey = sc.owner,
authority = citation,
// Every head our own fold honors (bans included) must survive the compaction.
mustCarry = ConcordRefounding.headVersions(chain, sc.communityId.hexToByteArray(), sc.owner),
)
} catch (e: IncompleteControlPlaneException) {
return Output.error("control_plane_incomplete", "${e.missing.size} Control Plane head(s) could not be carried into the new epoch; aborted before publishing the rotation (CORD-06 §3)")
}
// 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it). // 4. The root roll FIRST, every chunk confirmed; the compacted plane only after it
build.controlWraps.forEach { ctx.publish(it, relays) } // (CORD-06 §3). A chunk no relay took aborts with nothing adopted — the reserved keys
build.rekeyWraps.forEach { ctx.publish(it, relays) } // make re-running this command re-deliver the same root.
for (wrap in build.rekeyWraps) {
if (ctx.publish(wrap, relays).values.none { it.accepted }) {
return Output.error("rekey_not_published", "a rekey chunk was not accepted by any relay; re-run to resume with the same keys")
}
}
val compactionFailures = build.controlWraps.count { wrap -> ctx.publish(wrap, relays).values.none { it.accepted } }
// 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the // 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the
// epoch we are leaving for the anti-rollback floor. // epoch we are leaving for the anti-rollback floor — and drop the reservation.
val adopted = val adopted =
ConcordReceive.withAdoptedRoot( ConcordReceive.withAdoptedRoot(
ConcordCommands.entryFor(loaded.community), ConcordCommands.entryFor(loaded.community),
newRoot, keys.newRoot,
build.newEpoch, build.newEpoch,
build.newControlKeys.address.hexToByteArray(), build.newControlKeys.address.hexToByteArray(),
newControlRoot, keys.newControlRoot,
) )
val stored = ConcordCommands.storedFrom(loaded.community, adopted) val stored = ConcordCommands.storedFrom(loaded.community, adopted).copy(pendingRefounding = null)
ConcordStore(dataDir.concordFile).upsert(stored) ConcordStore(dataDir.concordFile).upsert(stored)
// 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new // 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new
@@ -400,7 +445,7 @@ object ConcordModCommands {
// grants, icon, label — survive the rotation, and so a coordinate whose newest // grants, icon, label — survive the rotation, and so a coordinate whose newest
// event is a revocation tombstone is left revoked instead of being re-opened. // event is a revocation tombstone is left revoked instead of being re-opened.
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second } val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second }
val live = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching val live = ConcordActions.classifyInvite(wraps, link.signerPubKeyHex(), token) as? InviteBundleStatus.Live ?: return@runCatching
val moved = val moved =
live.invite.copy( live.invite.copy(
communityRoot = stored.root, communityRoot = stored.root,
@@ -422,6 +467,7 @@ object ConcordModCommands {
"recipients" to recipients.size, "recipients" to recipients.size,
"control_wraps" to build.controlWraps.size, "control_wraps" to build.controlWraps.size,
"rekey_wraps" to build.rekeyWraps.size, "rekey_wraps" to build.rekeyWraps.size,
"compaction_failures" to compactionFailures,
"invites_refreshed" to refreshed, "invites_refreshed" to refreshed,
), ),
) )
@@ -57,6 +57,9 @@ data class StoredCommunity(
// A private channel is read and written ONLY on the plane its own key derives; without one it // 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. // is unreadable and `send` refuses rather than fall back to the root-derived plane.
val privateChannels: List<StoredPrivateChannel> = emptyList(), val privateChannels: List<StoredPrivateChannel> = emptyList(),
// Keys reserved for a Refounding this account started but has not yet adopted (CORD-06 §3): a
// retried `refound` must re-deliver the SAME root, never mint a sibling that splits the members.
val pendingRefounding: StoredPendingRefounding? = null,
) )
/** A held Private Channel key at its channel epoch, mirroring quartz `PrivateChannelKey`. */ /** A held Private Channel key at its channel epoch, mirroring quartz `PrivateChannelKey`. */
@@ -67,6 +70,14 @@ data class StoredPrivateChannel(
val name: String = "", val name: String = "",
) )
/** A Refounding's reserved keys, mirroring quartz `PendingRefounding`. */
data class StoredPendingRefounding(
val rootEpoch: Long = 0,
val prevCommit: String = "",
val newRoot: String = "",
val newControlRoot: String = "",
)
/** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */ /** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */
data class StoredHeldRoot( data class StoredHeldRoot(
val epoch: Long = 0, val epoch: Long = 0,
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite
@@ -281,6 +282,19 @@ object ConcordActions {
rootEpoch: Long, rootEpoch: Long,
): GroupKey = ConcordKeyDerivation.baseRekeyAddress(communityRoot, communityId, rootEpoch + 1) ): GroupKey = ConcordKeyDerivation.baseRekeyAddress(communityRoot, communityId, rootEpoch + 1)
/**
* The base-rekey address the rotation INTO [entry]'s current epoch rode on, derived from the
* prior epoch's (canonical) held root — or null when we hold none (a fresh joiner at this
* epoch). Watching it after adopting is what lets the same-epoch race heal (CORD-06 §3): a
* racing sibling rotation sealed under the same prior root arrives here, and a strictly lower
* one replaces the root we adopted.
*/
fun siblingBaseRekeyPlane(entry: ConcordCommunityListEntry): GroupKey? {
if (entry.rootEpoch <= 0) return null
val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).firstOrNull { it.epoch == entry.rootEpoch - 1 } ?: return null
return ConcordKeyDerivation.baseRekeyAddress(prior.key.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch)
}
// ---- relay filters (what to REQ) ----------------------------------------- // ---- relay filters (what to REQ) -----------------------------------------
/** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */ /** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */
@@ -551,6 +565,8 @@ object ConcordActions {
name: String, name: String,
relays: List<String>, relays: List<String>,
controlPk: HexKey? = null, controlPk: HexKey? = null,
creator: HexKey? = null,
label: String? = null,
): CommunityInvite = ): CommunityInvite =
CommunityInvite( CommunityInvite(
communityId = communityIdHex, communityId = communityIdHex,
@@ -561,6 +577,10 @@ object ConcordActions {
controlPk = controlPk, controlPk = controlPk,
relays = relays, relays = relays,
name = name, name = name,
// Optional attribution (CORD-05 §1): echoed in the joiner's Guestbook Join, so link
// holders can count per-link usage. Inside the token-encrypted bundle only.
creatorNpub = creator,
label = label,
) )
/** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */ /** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */
@@ -620,15 +640,25 @@ object ConcordActions {
fun bareInviteRef(url: String): String? = ConcordInviteLink.bareForm(url) fun bareInviteRef(url: String): String? = ConcordInviteLink.bareForm(url)
/** /**
* Merges a stranded membership forward onto a higher-epoch [bundle] resolved at * True when a live [bundle] resolved at [entry]'s own stored invite link says a Refounding
* its own stored invite link, or null when there is nothing to recover. See * left us behind (a higher epoch, and we are not banned). Detection only: a bundle may never
* [ConcordStrandedRecovery]. * move a held community's base on its own (CORD-06 §2) — see [ConcordStrandedRecovery].
*/ */
fun recoverStranded( fun isStranded(
entry: ConcordCommunityListEntry, entry: ConcordCommunityListEntry,
bundle: CommunityInvite, bundle: CommunityInvite,
bannedAtCurrentEpoch: Boolean, bannedAtCurrentEpoch: Boolean,
): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle, bannedAtCurrentEpoch) ): Boolean = ConcordStrandedRecovery.isStranded(entry, bundle, bannedAtCurrentEpoch)
/**
* The entry after the user **explicitly** re-accepts the invite link a stranded [entry] was
* joined through, or null when not stranded. Only ever from a user action — never a sweep.
*/
fun rejoinStranded(
entry: ConcordCommunityListEntry,
bundle: CommunityInvite,
bannedAtCurrentEpoch: Boolean,
): ConcordCommunityListEntry? = ConcordStrandedRecovery.rejoinForward(entry, bundle, bannedAtCurrentEpoch)
/** Decrypts + validates a fetched bundle event with the link token; null if invalid. */ /** Decrypts + validates a fetched bundle event with the link token; null if invalid. */
fun openBundle( fun openBundle(
@@ -641,13 +671,15 @@ object ConcordActions {
* [InviteBundleStatus] (live / expired / revoked / unreadable / absent) per CORD-05 * [InviteBundleStatus] (live / expired / revoked / unreadable / absent) per CORD-05
* §2, so a redeeming client honours a `vsk=9` revocation tombstone and an * §2, so a redeeming client honours a `vsk=9` revocation tombstone and an
* `expires_at` in the past, and reports why a link can't be opened instead of * `expires_at` in the past, and reports why a link can't be opened instead of
* retrying blindly. [nowMs] is unix milliseconds. * retrying blindly. [nowMs] is unix milliseconds. Only events genuinely at the link's
* coordinate count — signed by [linkSignerPubKey], `d == ""` — never what a relay claims is.
*/ */
fun classifyInvite( fun classifyInvite(
wraps: List<Event>, wraps: List<Event>,
linkSignerPubKey: HexKey,
token: ByteArray, token: ByteArray,
nowMs: Long = TimeUtils.nowMillis(), nowMs: Long = TimeUtils.nowMillis(),
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token, nowMs) ): InviteBundleStatus = ConcordInviteBundle.classify(wraps, linkSignerPubKey, token, nowMs)
/** /**
* The Control Plane keys described by a redeemed [invite] so the joiner can * The Control Plane keys described by a redeemed [invite] so the joiner can
@@ -735,6 +767,8 @@ object ConcordActions {
staffXOnly: Set<HexKey>, staffXOnly: Set<HexKey>,
createdAt: Long, createdAt: Long,
ownerPubKey: HexKey, ownerPubKey: HexKey,
authority: AuthorityCitation? = null,
mustCarry: Map<String, Long> = emptyMap(),
): RefoundingBuild = ): RefoundingBuild =
ConcordRefounding.build( ConcordRefounding.build(
rotatorSigner = rotatorSigner, rotatorSigner = rotatorSigner,
@@ -749,6 +783,8 @@ object ConcordActions {
staffXOnly = staffXOnly, staffXOnly = staffXOnly,
createdAt = createdAt, createdAt = createdAt,
ownerPubKey = ownerPubKey, ownerPubKey = ownerPubKey,
authority = authority,
mustCarry = mustCarry,
) )
/** /**
@@ -757,7 +793,9 @@ object ConcordActions {
* scope, epoch and continuity against the [priorRoot] the member holds — and, * scope, epoch and continuity against the [priorRoot] the member holds — and,
* on a staff blob, that the delivered `control_root` derives to the delivered * on a staff blob, that the delivered `control_root` derives to the delivered
* `control_pk` (CORD-06 §1). Returns the new root + Control keys + rotator * `control_pk` (CORD-06 §1). Returns the new root + Control keys + rotator
* (for the caller to authorize) or null if not re-keyed. * or null if not re-keyed. [accept] is the caller's authority check (see
* [ConcordReceive.isHonoredRotation]); racing rotations it admits converge on
* the lowest new root (CORD-06 §3).
*/ */
suspend fun openBaseRekey( suspend fun openBaseRekey(
wraps: List<Event>, wraps: List<Event>,
@@ -766,5 +804,6 @@ object ConcordActions {
communityId: HexKey, communityId: HexKey,
priorRoot: ByteArray, priorRoot: ByteArray,
rootEpoch: Long, rootEpoch: Long,
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch) accept: (ReceivedRefounding) -> Boolean = { true },
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch, accept)
} }
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
@@ -29,6 +30,9 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRotationAuthority
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -106,6 +110,71 @@ object ConcordReceive {
rotator: HexKey, rotator: HexKey,
): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN) ): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN)
/**
* Whether a received base rotation may be adopted (CORD-06 §3 "Authority"): its rotator holds
* BAN (or is the owner) in our fold, AND it cites the Grant it acts under (`vac`) at a version
* our fold has synced — so a just-demoted admin's rotation is never honored by a client that
* lags the demotion, and a rotation citing a Grant we have not seen yet waits for it. The owner
* cites nothing. [editions] are the current epoch's Control editions the citation is checked
* against.
*/
fun isHonoredRotation(
entry: ConcordCommunityListEntry,
editions: Collection<ControlEdition>,
authority: AuthorityResolver,
received: ReceivedRefounding,
): Boolean {
if (!isAuthorizedRotator(authority, received.rotator)) return false
val heads = ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner)
return ConcordRotationAuthority.citationSatisfied(entry.id, received.rotator, entry.owner, received.authority, heads)
}
/**
* The `vac` citation [actor] stamps on a rotation it launches (CORD-06 §3): their own Grant's
* head in our fold, or null for the owner (who cites nothing).
*/
fun rotationCitation(
entry: ConcordCommunityListEntry,
editions: Collection<ControlEdition>,
actor: HexKey,
): AuthorityCitation? = ConcordRotationAuthority.citationFor(entry.id, actor, entry.owner, ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner))
/**
* The down-only same-epoch heal (CORD-06 §3): [entry] holds a root at its current epoch, and
* [sibling] is a rotation to that same epoch (from the same prior root) that we did not adopt.
* Returns the entry moved onto the sibling when its root is **strictly lower** — the losing
* (higher) root we held is kept as a held root of the same epoch, so the messages sent into
* that fork stay readable — or null when the sibling does not win.
*
* The losing root keeps no control material: its Control Plane is the losing fork's
* compaction, not the community's ([ConcordRefounding.canonicalHeldRoots] never folds it).
*/
fun withHealedRoot(
entry: ConcordCommunityListEntry,
sibling: ReceivedRefounding,
): ConcordCommunityListEntry? {
if (sibling.newEpoch != entry.rootEpoch) return null
if (!ConcordRefounding.healsTo(entry.root.hexToByteArray(), sibling.newRoot)) return null
return ConcordCommunityListEntry(
id = entry.id,
owner = entry.owner,
ownerSalt = entry.ownerSalt,
root = sibling.newRoot.toHexKey(),
rootEpoch = entry.rootEpoch,
// The control pair is the winner's blob's, never inherited from the losing fork.
controlPk = sibling.newControlPk?.toHexKey(),
controlRoot = sibling.newControlRoot?.toHexKey(),
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch to it.key.lowercase() },
privateChannels = entry.privateChannels,
relays = entry.relays,
name = entry.name,
addedAt = entry.addedAt,
inviteRef = entry.inviteRef,
excludedAtEpoch = entry.excludedAtEpoch,
residue = entry.residue,
)
}
/** /**
* The entry that results from adopting a base rotation to [newEpoch] — a pure rewrite, so the * The entry that results from adopting a base rotation to [newEpoch] — a pure rewrite, so the
* caller can diff, persist and publish it however its platform does. * caller can diff, persist and publish it however its platform does.
@@ -133,7 +202,9 @@ object ConcordReceive {
rootEpoch = newEpoch, rootEpoch = newEpoch,
controlPk = newControlPk?.toHexKey(), controlPk = newControlPk?.toHexKey(),
controlRoot = newControlRoot?.toHexKey(), controlRoot = newControlRoot?.toHexKey(),
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }, // Keyed by (epoch, key), not epoch alone: a healed race leaves a losing fork's root at the
// same epoch, kept so its messages stay readable (CORD-06 §3).
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch to it.key.lowercase() },
privateChannels = entry.privateChannels, privateChannels = entry.privateChannels,
relays = entry.relays, relays = entry.relays,
name = entry.name, name = entry.name,
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -78,7 +79,9 @@ object ConcordSubscriptionPlanner {
// the entry, per epoch, exactly as it was delivered. // the entry, per epoch, exactly as it was delivered.
val cp = ConcordActions.controlPlaneKeysFor(e) val cp = ConcordActions.controlPlaneKeysFor(e)
val historical = val historical =
e.heldRoots // Losing-fork roots of a healed race are kept for their messages only (CORD-06 §3).
ConcordRefounding
.canonicalHeldRoots(e.heldRoots)
.filter { it.epoch < e.rootEpoch } .filter { it.epoch < e.rootEpoch }
.sortedByDescending { it.epoch } .sortedByDescending { it.epoch }
.take(ConcordActions.MAX_BACKFILL_EPOCHS) .take(ConcordActions.MAX_BACKFILL_EPOCHS)
@@ -104,10 +107,13 @@ object ConcordSubscriptionPlanner {
val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch) val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch)
val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch) val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch)
val dissolved = ConcordDissolution.planeKey(e.id) val dissolved = ConcordDissolution.planeKey(e.id)
listOf( val sibling = ConcordActions.siblingBaseRekeyPlane(e)
listOfNotNull(
ConcordPlaneSub(channelId = null, pubKeyHex = guestbook.publicKeyHex, relays = relays), ConcordPlaneSub(channelId = null, pubKeyHex = guestbook.publicKeyHex, relays = relays),
ConcordPlaneSub(channelId = null, pubKeyHex = nextRekey.publicKeyHex, relays = relays), ConcordPlaneSub(channelId = null, pubKeyHex = nextRekey.publicKeyHex, relays = relays),
ConcordPlaneSub(channelId = null, pubKeyHex = dissolved.publicKeyHex, relays = relays), ConcordPlaneSub(channelId = null, pubKeyHex = dissolved.publicKeyHex, relays = relays),
// The current epoch's own rekey address, so a racing sibling can heal us (CORD-06 §3).
sibling?.let { ConcordPlaneSub(channelId = null, pubKeyHex = it.publicKeyHex, relays = relays) },
) )
} }
@@ -58,6 +58,10 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
@@ -65,7 +69,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PagedFetchResult
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
@@ -83,6 +89,9 @@ import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** Name of the default Concord community Admin role minted by "Make admin". */ /** Name of the default Concord community Admin role minted by "Make admin". */
private const val CONCORD_ADMIN_ROLE = "Admin" private const val CONCORD_ADMIN_ROLE = "Admin"
@@ -104,6 +113,9 @@ private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
*/ */
private const val MAX_REFOUNDING_RECIPIENTS = 5_000 private const val MAX_REFOUNDING_RECIPIENTS = 5_000
/** A lowercase 32-byte hex key (the Guestbook `invite` tag's creator). */
private val HEX64 = Regex("^[0-9a-f]{64}$")
/** /**
* Concord (encrypted communities) orchestration for an [Account]: join/create/ * Concord (encrypted communities) orchestration for an [Account]: join/create/
* invite flows, channel messages/reactions/edits/typing, roles and moderation, * invite flows, channel messages/reactions/edits/typing, roles and moderation,
@@ -332,7 +344,7 @@ class AccountConcordActions(
val token = link.token.hexToByteArray() val token = link.token.hexToByteArray()
// Classify per coordinate, never over the pooled set: one link's newer // Classify per coordinate, never over the pooled set: one link's newer
// revocation tombstone must not decide another link's status. // revocation tombstone must not decide another link's status.
val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), token) as? InviteBundleStatus.Live ?: return@runCatching false val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), author, token) as? InviteBundleStatus.Live ?: return@runCatching false
val moved = val moved =
current.invite.copy( current.invite.copy(
communityRoot = entry.root, communityRoot = entry.root,
@@ -390,7 +402,11 @@ class AccountConcordActions(
// The joiner can never derive the Control Plane address, so the bundle carries // The joiner can never derive the Control Plane address, so the bundle carries
// it (CORD-05 §1). Null on a legacy community, which has none to carry. // it (CORD-05 §1). Null on a legacy community, which has none to carry.
controlPk = entry.controlPk, controlPk = entry.controlPk,
// Attribution the joiner echoes in their Guestbook Join (CORD-05 §1).
creator = account.signer.pubKey,
) )
// The fragment carries at most 3 bootstrap relays (CORD-05 §3); the codec truncates a longer
// list (the stock set stays a single flag), and the bundle keeps the full relay set.
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays)
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
@@ -535,7 +551,7 @@ class AccountConcordActions(
// stale openable copy) so we honour revocation and can tell the user *why* a link won't open // stale openable copy) so we honour revocation and can tell the user *why* a link won't open
// instead of stranding them on a spinner that retries a link we can never redeem. // instead of stranding them on a spinner that retries a link we can never redeem.
val bundle = val bundle =
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { when (val status = ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token)) {
is InviteBundleStatus.Live -> status.invite is InviteBundleStatus.Live -> status.invite
is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired
InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked
@@ -547,10 +563,25 @@ class AccountConcordActions(
// Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an
// old invite is reopened, so short-circuit to Joined — the screen forwards to the community // old invite is reopened, so short-circuit to Joined — the screen forwards to the community
// either way ("take me there", not "join again"). // either way ("take me there", not "join again").
if (account.concordChannelList.liveCommunities.value //
.any { it.id == bundle.communityId } // The one exception is a membership a Refounding left behind: the background sweep only
) { // DETECTS that (a bundle may never move a held community's base on its own, CORD-06 §2), so
return ConcordInviteResult.Joined(bundle.communityId) // the user explicitly re-accepting the link is the way forward — the same trust decision as
// their first join, taken by them.
val held =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == bundle.communityId }
var rejoined: ConcordCommunityListEntry? = null
if (held != null) {
val heldState =
account.concordSessions
.sessionFor(held.id)
?.state
?.value
// Death wins every race (CORD-02 §9): nothing moves a dissolved community forward.
if (heldState == null || heldState.dissolved) return ConcordInviteResult.Joined(bundle.communityId)
rejoined = ConcordActions.rejoinStranded(held, bundle, heldState.authority.isBanned(account.signer.pubKey))
?: return ConcordInviteResult.Joined(bundle.communityId)
} }
// Refuse a link that readmits us after we were removed. A Refounding re-mints every // Refuse a link that readmits us after we were removed. A Refounding re-mints every
@@ -589,6 +620,19 @@ class AccountConcordActions(
return ConcordInviteResult.Banned return ConcordInviteResult.Banned
} }
// Invite attribution (CORD-05 §1): the joiner echoes the link's creator + label in their
// Guestbook Join, which is what makes per-link usage counters possible.
val inviteCreator = bundle.creatorNpub?.lowercase()?.takeIf { HEX64.matches(it) }
val inviteLabel = bundle.label?.takeIf { inviteCreator != null && it.isNotBlank() }
if (rejoined != null) {
if (!adoptedConcordRotations.add("${rejoined.id}:${rejoined.rootEpoch}")) return ConcordInviteResult.Joined(bundle.communityId)
Log.i("Concord") { "Stranded rejoin by explicit invite: ${rejoined.id} -> epoch ${rejoined.rootEpoch}" }
joinConcordCommunity(rejoined, inviteCreator, inviteLabel)
_strandedConcordCommunities.value -= rejoined.id
return ConcordInviteResult.Joined(bundle.communityId)
}
val entry = val entry =
ConcordCommunityListEntry( ConcordCommunityListEntry(
id = bundle.communityId, id = bundle.communityId,
@@ -610,7 +654,7 @@ class AccountConcordActions(
// recoverStrandedConcordCommunities(). // recoverStrandedConcordCommunities().
inviteRef = ConcordActions.bareInviteRef(url), inviteRef = ConcordActions.bareInviteRef(url),
) )
joinConcordCommunity(entry) joinConcordCommunity(entry, inviteCreator, inviteLabel)
return ConcordInviteResult.Joined(bundle.communityId) return ConcordInviteResult.Joined(bundle.communityId)
} }
@@ -1200,6 +1244,12 @@ class AccountConcordActions(
if (!account.isWriteable()) return false if (!account.isWriteable()) return false
val session = account.concordSessions.sessionFor(communityId) ?: return false val session = account.concordSessions.sessionFor(communityId) ?: return false
val state = session.state.value ?: return false val state = session.state.value ?: return false
// Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored, so a
// Refounding of a dissolved community would only strand whoever follows it.
if (state.dissolved) {
Log.w("Concord") { "Refusing to refound ${session.entry.id}: the community was dissolved (CORD-02 §9)" }
return false
}
val authority = state.authority val authority = state.authority
// hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol // hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol
// and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the // and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the
@@ -1221,6 +1271,19 @@ class AccountConcordActions(
// compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A // compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A
// rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery. // rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery.
val cp = controlKeysForWrite(session) ?: return false val cp = controlKeysForWrite(session) ?: return false
val entry = session.entry
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (publishTo.isEmpty()) return false
// 0. Acquire the whole Control Plane BEFORE the first publish (CORD-06 §3: "If the Refounder
// cannot reliably fold all Control events, the Refounding must be aborted"). The live
// buffer is whatever the subscription happened to deliver; a paged sweep that a majority
// of the community's relays drained is what makes the compaction the whole plane.
val swept = sweepConcordControlPlane(cp.address, publishTo)
if (swept == null) {
Log.w("Concord") { "Refounding ${entry.id} aborted: too few relays served the whole Control Plane" }
return false
}
// 1. Ban the removed members on the current Control Plane so the compacted snapshot — // 1. Ban the removed members on the current Control Plane so the compacted snapshot —
// and thus the new epoch — carries the ban. publishConcordWrap folds it in locally // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally
@@ -1240,7 +1303,7 @@ class AccountConcordActions(
// //
// Still a floor, not a census (see allMembers): a member who joined without a Guestbook // Still a floor, not a census (see allMembers): a member who joined without a Guestbook
// motion, holds no role, and has never posted leaves no trace to find, so a Refounding // motion, holds no role, and has never posted leaves no trace to find, so a Refounding
// cannot re-key them. Stranded recovery is what gets those members back. // cannot re-key them.
val recipients = val recipients =
(session.allMembers() + account.signer.pubKey) (session.allMembers() + account.signer.pubKey)
.mapTo(HashSet()) { it.lowercase() } .mapTo(HashSet()) { it.lowercase() }
@@ -1250,52 +1313,113 @@ class AccountConcordActions(
}.let { candidates -> boundRecipients(candidates, authority) } }.let { candidates -> boundRecipients(candidates, authority) }
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
val entry = session.entry // The keys are RESERVED per (epoch, prior root): a retry after a failed publish re-delivers
val newRoot = RandomInstance.bytes(32) // the same root instead of minting a sibling that would split the members (CORD-06 §3).
// A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2), // A fresh control_root rides beside the new root at every Refounding (CORD-02 §2), so a
// so a demoted staffer's retained secret dies with the epoch — and a legacy community // demoted staffer's retained secret dies with the epoch — and a legacy community upgrades
// upgrades to the split as a side effect of its next ban (CORD-06 §3). // to the split as a side effect of its next ban (CORD-06 §3).
val newControlRoot = RandomInstance.bytes(32) val priorRoot = entry.root.hexToByteArray()
val keys = ConcordRefounding.reserveKeys(pendingConcordRefoundings[entry.id], entry.id, entry.rootEpoch, priorRoot)
pendingConcordRefoundings[entry.id] = keys
val editions = session.controlEditions()
// The rotation cites the Grant it acts under (CORD-06 §3 "Authority"); the owner cites none.
// A non-owner with no Grant in our own fold has nothing to cite, so no receiver would honor it.
val citation = ConcordReceive.rotationCitation(entry, editions, account.signer.pubKey)
if (citation == null && !authority.isOwner(account.signer.pubKey)) {
Log.w("Concord") { "Refounding ${entry.id} aborted: no Grant of ours to cite (CORD-06 §3)" }
return false
}
// The staff set the new secret goes to: the owner plus everyone holding a // The staff set the new secret goes to: the owner plus everyone holding a
// Control-writing bit (CORD-04 §3). They get the 136-byte blob, every other // Control-writing bit (CORD-04 §3). They get the 136-byte blob, every other
// recipient the 104-byte one carrying the pubkey alone. (The builder mints a // recipient the 104-byte one carrying the pubkey alone. (The builder mints a
// blob per recipient, so staff who aren't recipients are simply never reached.) // blob per recipient, so staff who aren't recipients are simply never reached.)
val staff = authority.staffMembers() val staff = authority.staffMembers()
val build = val build =
ConcordActions.buildRefounding( try {
rotatorSigner = account.signer, ConcordActions.buildRefounding(
communityId = communityId, rotatorSigner = account.signer,
priorRoot = entry.root.hexToByteArray(), communityId = communityId,
newRoot = newRoot, priorRoot = priorRoot,
newControlRoot = newControlRoot, newRoot = keys.newRoot,
rootEpoch = entry.rootEpoch, newControlRoot = keys.newControlRoot,
priorControlWraps = session.controlPlaneWraps(), rootEpoch = entry.rootEpoch,
priorControlKeys = cp, priorControlWraps = (session.controlPlaneWraps() + swept).distinctBy { it.id },
recipientsXOnly = recipients, priorControlKeys = cp,
staffXOnly = staff, recipientsXOnly = recipients,
createdAt = TimeUtils.now(), staffXOnly = staff,
ownerPubKey = entry.owner, createdAt = TimeUtils.now(),
) ownerPubKey = entry.owner,
authority = citation,
// Every head our own fold honors must survive into the new epoch.
mustCarry = ConcordRefounding.headVersions(editions, entry.id.hexToByteArray(), entry.owner),
)
} catch (e: IncompleteControlPlaneException) {
Log.w("Concord", "Refounding ${entry.id} aborted: the Control Plane could not be folded in full", e)
return false
}
// 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs // 4. The root roll FIRST, each chunk confirmed (CORD-06 §3): the compacted plane is
// (the key that unlocks it) to the community relays. // republished only after the rekey blobs are known to have landed. A chunk no relay
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } // accepted aborts here with nothing adopted; the reserved keys make the retry idempotent.
if (publishTo.isNotEmpty()) { for (wrap in build.rekeyWraps) {
build.controlWraps.forEach { account.client.publish(it, publishTo) } if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) {
build.rekeyWraps.forEach { account.client.publish(it, publishTo) } Log.w("Concord") { "Refounding ${entry.id} aborted: a rekey chunk was not accepted by any relay; retrying reuses the same root" }
return false
}
} }
// 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and // 5. The compacted Control Plane, at the new epoch's address. The root roll is committed, so
// re-folds the compacted Control Plane (with the ban), dropping the removed members. // a head that fails to land is reported, not rolled back — members already hold the new
val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot) // root, and the next Refounding re-compacts from the same signed heads.
var compactionLanded = true
for (wrap in build.controlWraps) {
if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) compactionLanded = false
}
if (!compactionLanded) Log.w("Concord") { "Refounding ${entry.id}: some compacted Control Plane heads were not accepted at epoch ${build.newEpoch}" }
// 6. Move every link we minted to the new epoch. Without this the Refounding orphans them, // 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)
pendingConcordRefoundings.remove(entry.id)
// 7. Move every link we minted to the new epoch. Without this the Refounding orphans them,
// and a member it left out — no rekey blob, no message to miss — has no way back at all. // and a member it left out — no rekey blob, no message to miss — has no way back at all.
// Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so // Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so
// reading it here would hand us the epoch we just left and re-mint every link onto it. // reading it here would hand us the epoch we just left and re-mint every link onto it.
val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0 val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0
Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" } Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" }
return true return compactionLanded
}
// 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.
private val pendingConcordRefoundings = ConcurrentMap<String, PendingRefounding>()
/**
* Pages the whole Control Plane at [address] off every relay in [relays], or null when fewer
* than a majority of them drained it (a dead relay must not block rotation forever, but too few
* would compact a partial plane and roll the community back for everyone who follows).
*/
private suspend fun sweepConcordControlPlane(
address: HexKey,
relays: Set<NormalizedRelayUrl>,
): List<Event>? {
val filter = ConcordActions.planeFilter(address)
val perRelay =
coroutineScope {
relays
.map { relay ->
async {
val events = ArrayList<Event>()
val result = runCatching { account.client.fetchAllPages(relay, listOf(filter)) { events.add(it) } }.getOrNull()
if (result?.end == PagedFetchResult.End.DRAINED) events else null
}
}.awaitAll()
}
val drained = perRelay.filterNotNull()
if (drained.size < relays.size / 2 + 1) return null
return drained.flatten().distinctBy { it.id }
} }
/** /**
@@ -1392,43 +1516,79 @@ class AccountConcordActions(
* *
* A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list, * A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list,
* so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the * so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the
* owner included) by omission — nothing on this receive path can prevent it. The * owner included) by omission — nothing on this receive path can prevent it.
* cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves * [recoverStrandedConcordCommunities] detects it; re-opening the invite link rejoins.
* the invite link the membership was joined through and merges forward. *
* Also runs the same-epoch race heal (CORD-06 §3): racing rotations converge on the
* lowest authorized root, and a sibling seen after we adopted replaces our root only
* when strictly lower. Nothing is adopted for a dissolved community (CORD-02 §9).
*/ */
internal suspend fun drainConcordRekeys() { internal suspend fun drainConcordRekeys() {
if (!account.isWriteable()) return if (!account.isWriteable()) return
for (session in account.concordSessions.sessions()) { for (session in account.concordSessions.sessions()) {
val wraps = session.pendingBaseRekeyWraps()
if (wraps.isEmpty()) continue
val entry = session.entry val entry = session.entry
val received = val state = session.state.value ?: continue
// Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored.
if (state.dissolved) continue
val authority = state.authority
val editions = session.controlEditions()
// Authority is the roster plus the cited Grant, never key possession (CORD-06 §3):
// hasPermission (not effectivePermissions, which ignores the banlist) and a `vac` our
// fold has synced, so a just-demoted admin's rotation is not honored while we lag.
val honored = { r: ReceivedRefounding -> ConcordReceive.isHonoredRotation(entry, editions, authority, r) }
val wraps = session.pendingBaseRekeyWraps()
if (wraps.isNotEmpty()) {
// Racing authorized rotations converge on the lowest new root (CORD-06 §3).
val received =
ConcordActions.openBaseRekey(
wraps = wraps,
baseRekey = session.nextBaseRekeyKey(),
recipientSigner = account.signer,
communityId = entry.id,
priorRoot = entry.root.hexToByteArray(),
rootEpoch = entry.rootEpoch,
accept = honored,
)
if (received != null && received.newEpoch > entry.rootEpoch) {
val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
// A rotation we did not launch superseded the one we may have had in flight.
pendingConcordRefoundings.remove(entry.id)
// Move our own links onto the epoch we just adopted. Rotating is not the only way
// to end up on a new epoch — being re-keyed is the common one — and a link creator
// who is merely re-keyed would otherwise leave every link they handed out pointing
// at the dead root.
adopted?.let { next ->
val moved = refreshConcordInviteLinks(next)
if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" }
}
continue
}
}
// The same-epoch heal (CORD-06 §3): a racing rotation into the epoch we hold, sealed under
// the same prior root, wins only when its root is strictly lower. Our losing root stays
// held so the messages sent into that fork stay readable.
val siblingKey = session.siblingBaseRekeyKey() ?: continue
val siblingWraps = session.pendingSiblingRekeyWraps()
if (siblingWraps.isEmpty()) continue
val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).firstOrNull { it.epoch == entry.rootEpoch - 1 } ?: continue
val sibling =
ConcordActions.openBaseRekey( ConcordActions.openBaseRekey(
wraps = wraps, wraps = siblingWraps,
baseRekey = session.nextBaseRekeyKey(), baseRekey = siblingKey,
recipientSigner = account.signer, recipientSigner = account.signer,
communityId = entry.id, communityId = entry.id,
priorRoot = entry.root.hexToByteArray(), priorRoot = prior.key.hexToByteArray(),
rootEpoch = entry.rootEpoch, rootEpoch = prior.epoch,
accept = honored,
) ?: continue ) ?: continue
if (received.newEpoch <= entry.rootEpoch) continue val healed = ConcordReceive.withHealedRoot(entry, sibling) ?: continue
val authority = session.state.value?.authority ?: continue if (!adoptedConcordRotations.add("${healed.id}:${healed.rootEpoch}:${healed.root}")) continue
Log.i("Concord") { "Rekey race ${entry.id}: epoch ${entry.rootEpoch} converged on the lower sibling root" }
// hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(healed))
// who has themselves been banned could still rotate the whole community. announceConcordGuestbookJoin(healed, inviteCreator = null, inviteLabel = null)
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) refreshConcordInviteLinks(healed)
if (!authorized) continue
val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
// Move our own links onto the epoch we just adopted. Rotating is not the only way to end
// up on a new epoch — being re-keyed is the common one — and a link creator who is merely
// re-keyed would otherwise leave every link they handed out pointing at the dead root,
// which is exactly the orphaning this branch exists to stop. Stranded recovery reads the
// bundle's epoch, so a link nobody re-mints is a member nobody can recover.
adopted?.let { next ->
val moved = refreshConcordInviteLinks(next)
if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" }
}
} }
} }
@@ -1462,6 +1622,14 @@ class AccountConcordActions(
} }
} }
private val _strandedConcordCommunities = MutableStateFlow<Set<String>>(emptySet())
/**
* Communities whose own invite link resolves to a higher epoch than we hold — a Refounding left
* us behind (see [recoverStrandedConcordCommunities]). Re-opening that link rejoins them.
*/
val strandedConcordCommunities: StateFlow<Set<String>> = _strandedConcordCommunities.asStateFlow()
// Last time we re-resolved each community's invite_ref, so the recovery sweep rides the // Last time we re-resolved each community's invite_ref, so the recovery sweep rides the
// Concord revision tick (which fires on every structural change) without turning it into a // Concord revision tick (which fires on every structural change) without turning it into a
// relay-fetch loop. // relay-fetch loop.
@@ -1475,20 +1643,18 @@ class AccountConcordActions(
* included, and [drainConcordRekeys] cannot prevent it: there is no message to * included, and [drainConcordRekeys] cannot prevent it: there is no message to
* miss detecting. * miss detecting.
* *
* The way back is the invite link the membership was joined through * The signal is the invite link the membership was joined through
* ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and * ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and
* carried through every rotation by [adoptConcordRoot]). The community keeps * carried through every rotation by [adoptConcordRoot]). The community keeps
* re-minting its bundle at that same addressable coordinate, so a bundle there at * re-minting its bundle at that same addressable coordinate, so a bundle there at
* a **strictly higher** epoch than ours proves we were left behind — and carries * a **strictly higher** epoch than ours says we were left behind. Memberships with
* the new root. Same or lower epoch is a no-op. Memberships with no link (direct * no link (direct invites, legacy entries) are inert here.
* invites, legacy entries) are inert here; that is expected, not an error.
* *
* The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps * Detection ONLY ([strandedConcordCommunities]). The bundle is not proof of
* both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the * continuity — nothing binds `community_root` to `community_id` — so adopting its
* entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays * root here would let any link creator relocate every member who joined through
* derivable). We then re-announce the Guestbook at the new epoch, exactly as an * their link (CORD-06 §2: the base advances only by a verifiable rekey). The way
* ordinary rotation does, so the recovered member is visible to whoever refounds * forward is the user explicitly re-opening the link ([joinConcordViaInvite]).
* next instead of being silently dropped again.
* *
* Called on the Concord revision tick, but rate-limited per community * Called on the Concord revision tick, but rate-limited per community
* ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup. * ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup.
@@ -1502,6 +1668,21 @@ class AccountConcordActions(
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
lastConcordRecoveryCheck[entry.id] = now lastConcordRecoveryCheck[entry.id] = now
// Fails CLOSED: no fold, no verdict — a banned member's cold-start window must not read
// as "not banned". Retried on the next sweep once the roster is known.
val state =
account.concordSessions
.sessionFor(entry.id)
?.state
?.value
if (state == null) {
Log.i("Concord") { "Stranded check deferred for ${entry.id}: control plane not folded yet" }
lastConcordRecoveryCheck.remove(entry.id)
continue
}
// Death wins every race (CORD-02 §9): a dissolved community is never "behind".
if (state.dissolved) continue
val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue
val relays = val relays =
( (
@@ -1512,39 +1693,14 @@ class AccountConcordActions(
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
val wraps = account.client.fetchAll(filters = filters) val wraps = account.client.fetchAll(filters = filters)
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed. // Only a live bundle counts: an expired/revoked link is not a rotation we missed.
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue val bundle = (ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
// A removed member holds the link's unlock token forever, so without this the sweep // Detection only (CORD-06 §2): the bundle is not proof of continuity, so it never moves
// walks them straight back into the epoch they were rotated out of — see A2 in // our base. The user re-accepting the link is the way forward (joinConcordViaInvite).
// docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last val stranded = ConcordActions.isStranded(entry, bundle, state.authority.isBanned(account.signer.pubKey))
// one whose Control Plane we can still fold. _strandedConcordCommunities.value = if (stranded) _strandedConcordCommunities.value + entry.id else _strandedConcordCommunities.value - entry.id
// if (stranded) Log.i("Concord") { "Stranded: ${entry.id} is at epoch ${entry.rootEpoch}, its invite link at ${bundle.rootEpoch}; re-open the link to rejoin" }
// Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not
// exist yet or whose first fold has not landed, and this sweep runs on the revision tick
// — so a banned member's own client would have hit that window on cold start and
// recovered itself, which is precisely the bypass this gate exists to stop. No verdict
// means no recovery; the next sweep retries once the roster is known.
val authority =
account.concordSessions
.sessionFor(entry.id)
?.state
?.value
?.authority
if (authority == null) {
Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" }
lastConcordRecoveryCheck.remove(entry.id)
continue
}
val bannedHere = authority.isBanned(account.signer.pubKey)
val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
Log.i("Concord") { "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}" }
if (!persistConcordEntry(merged)) {
adoptedConcordRotations.remove("${entry.id}:${merged.rootEpoch}")
continue
}
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
} }
} }
@@ -1671,7 +1827,13 @@ class AccountConcordActions(
if (relays.isEmpty()) return null if (relays.isEmpty()) return null
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
val wraps = account.client.fetchAll(filters = filters) val wraps = account.client.fetchAll(filters = filters)
return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } // Resolved like a join (newest per coordinate, signer-verified): a revoked link previews as
// nothing, never as the stale bundle a relay still serves. An expired one still renders.
return when (val status = ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token)) {
is InviteBundleStatus.Live -> status.invite
is InviteBundleStatus.Expired -> status.invite
else -> null
}
} }
/** /**
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
@@ -139,6 +140,13 @@ class ConcordCommunitySession(
*/ */
private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch) private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch)
/**
* The rekey address the rotation INTO this epoch rode on (from the prior held root), or null
* for a joiner who holds no prior root. A racing sibling rotation to this same epoch lands
* here; the app drains it for the down-only heal (CORD-06 §3).
*/
private val siblingBaseRekeyKey: GroupKey? = ConcordActions.siblingBaseRekeyPlane(entry)
/** /**
* The dissolution tombstone address (CORD-02 §9): derived from the community id alone, so it * The dissolution tombstone address (CORD-02 §9): derived from the community id alone, so it
* is the same for every epoch and every member past or present. * is the same for every epoch and every member past or present.
@@ -164,6 +172,9 @@ class ConcordCommunitySession(
/** The next-epoch base-rekey stream address to watch for an inbound Refounding. */ /** The next-epoch base-rekey stream address to watch for an inbound Refounding. */
val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex
/** The current epoch's own base-rekey address (see [siblingBaseRekeyKey]), or null. */
val siblingBaseRekeyAddress: HexKey? get() = siblingBaseRekeyKey?.publicKeyHex
/** /**
* The Control Plane of every **prior** epoch we still hold a root for (address -> * The Control Plane of every **prior** epoch we still hold a root for (address ->
* key + epoch), newest-held first and bounded like the channel backfill. * key + epoch), newest-held first and bounded like the channel backfill.
@@ -177,7 +188,9 @@ class ConcordCommunitySession(
* survives a process restart without any new storage. * survives a process restart without any new storage.
*/ */
private val historicalControlKeys: Map<HexKey, Pair<ControlPlaneKeys, Long>> = private val historicalControlKeys: Map<HexKey, Pair<ControlPlaneKeys, Long>> =
entry.heldRoots // Losing-fork roots of a healed race carry no Control Plane of the community's (CORD-06 §3).
ConcordRefounding
.canonicalHeldRoots(entry.heldRoots)
.filter { it.epoch < entry.rootEpoch } .filter { it.epoch < entry.rootEpoch }
.sortedByDescending { it.epoch } .sortedByDescending { it.epoch }
.take(ConcordActions.MAX_BACKFILL_EPOCHS) .take(ConcordActions.MAX_BACKFILL_EPOCHS)
@@ -239,6 +252,7 @@ class ConcordCommunitySession(
private val channelWrapsById = HashMap<HexKey, LinkedHashMap<HexKey, Event>>() // channelIdHex -> (wrapId -> wrap) private val channelWrapsById = HashMap<HexKey, LinkedHashMap<HexKey, Event>>() // channelIdHex -> (wrapId -> wrap)
private val guestbookWraps = LinkedHashMap<HexKey, Event>() private val guestbookWraps = LinkedHashMap<HexKey, Event>()
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>() private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
private val siblingRekeyWraps = LinkedHashMap<HexKey, Event>()
// Current channel plane pubkey -> plane (channel id, key, bound epoch), refreshed on each control // 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 // re-fold. A Public Channel's plane derives from the root at the root epoch; a Private one's from
@@ -335,6 +349,7 @@ class ConcordCommunitySession(
address == controlPlaneAddress || address == controlPlaneAddress ||
address == guestbookAddress || address == guestbookAddress ||
address == nextBaseRekeyAddress || address == nextBaseRekeyAddress ||
address == siblingBaseRekeyAddress ||
address == dissolvedAddress || address == dissolvedAddress ||
address in historicalControlKeys || address in historicalControlKeys ||
lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress } lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress }
@@ -381,6 +396,12 @@ class ConcordCommunitySession(
/** The buffered kind-3303 base-rotation wraps seen at [nextBaseRekeyAddress], for the account to drain. */ /** The buffered kind-3303 base-rotation wraps seen at [nextBaseRekeyAddress], for the account to drain. */
fun pendingBaseRekeyWraps(): List<Event> = lock.withLock { baseRekeyWraps.values.toList() } fun pendingBaseRekeyWraps(): List<Event> = lock.withLock { baseRekeyWraps.values.toList() }
/** The base-rekey [GroupKey] of the rotation into this epoch (sibling heal), or null. */
fun siblingBaseRekeyKey(): GroupKey? = siblingBaseRekeyKey
/** The buffered kind-3303 wraps seen at [siblingBaseRekeyAddress], for the account's heal drain. */
fun pendingSiblingRekeyWraps(): List<Event> = lock.withLock { siblingRekeyWraps.values.toList() }
/** /**
* Every stream key whose kind-1059 wraps this session reads: the Control Plane plus * Every stream key whose kind-1059 wraps this session reads: the Control Plane plus
* one per folded channel. These are the identities a NIP-42 relay must see the * one per folded channel. These are the identities a NIP-42 relay must see the
@@ -415,7 +436,7 @@ class ConcordCommunitySession(
* The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address) * The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address)
* for their own isolated AUTH. * for their own isolated AUTH.
*/ */
fun auxStreamKeys(): List<GroupKey> = listOf(guestbookKey, nextBaseRekeyKey, dissolvedKey) fun auxStreamKeys(): List<GroupKey> = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey)
/** The community's current Control Plane editions — the input a moderation edition chains onto. */ /** 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) } fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) }
@@ -542,6 +563,11 @@ class ConcordCommunitySession(
lock.withLock { baseRekeyWraps[wrap.id] = wrap } lock.withLock { baseRekeyWraps[wrap.id] = wrap }
return ConcordIngestOutcome.STRUCTURAL return ConcordIngestOutcome.STRUCTURAL
} }
siblingBaseRekeyAddress -> {
// Same as above for a racing rotation into THIS epoch (the down-only heal).
lock.withLock { siblingRekeyWraps[wrap.id] = wrap }
return ConcordIngestOutcome.STRUCTURAL
}
else -> { else -> {
// A prior-epoch Control Plane wrap: buffer it and re-fold, so the anti-rollback // 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 // floor rises as the old epochs drain in. Structural — the floor can change the
@@ -0,0 +1,263 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The receive side of CORD-06 rotations as commons wires it: the `vac`-cited authority check a
* receiver runs before adopting (I9), racing rotations converging on the lowest authorized root
* and the down-only same-epoch heal (I12), and the sibling address a session watches for it.
*/
class ConcordRotationReceiveTest {
private val owner = NostrSignerInternal(KeyPair())
private val admin = NostrSignerInternal(KeyPair())
private val member = NostrSignerInternal(KeyPair())
private val now = 1_700_000_000L
private class Fixture(
val community: NewConcordCommunity,
val editions: List<ControlEdition>,
)
/** A community whose owner granted [admin] a BAN role. */
private suspend fun withAdmin(): Fixture {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val cp = community.controlPlane
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
val roleId = ByteArray(32) { (it + 1).toByte() }
val role = RoleEntity(name = "Admin", position = 1, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire())
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role, editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
editions += ConcordActions.controlEditions(listOf(ConcordModeration.grant(owner, cp, community.communityId, admin.pubKey, listOf(roleId.toHexKey()), editions, createdAt = 3L, owner = community.ownerPubKey)), cp)
return Fixture(community, editions)
}
private fun entryFor(
community: NewConcordCommunity,
root: String = community.communityRoot.toHexKey(),
epoch: Long = community.rootEpoch,
heldRoots: List<HeldRoot> = emptyList(),
) = ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = root,
rootEpoch = epoch,
controlPk = community.controlPkHex,
heldRoots = heldRoots,
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
private suspend fun rotate(
community: NewConcordCommunity,
rotator: NostrSigner,
newRoot: ByteArray,
authority: AuthorityCitation?,
): List<Event> {
val newEpoch = community.rootEpoch + 1
val controlRoot = ByteArray(32) { 0x6B }
return ConcordRefounding.buildBaseRekeyWraps(
rotatorSigner = rotator,
baseRekeyKey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch),
recipientsXOnly = listOf(member.pubKey),
staffXOnly = emptySet(),
newRoot = newRoot,
newControlPk = ConcordKeyDerivation.controlSignerKey(controlRoot, community.communityId, newEpoch).publicKey,
newControlRoot = controlRoot,
newEpoch = newEpoch,
prevEpoch = community.rootEpoch,
prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey(),
createdAt = now,
authority = authority,
)
}
private suspend fun receive(
f: Fixture,
wraps: List<Event>,
): ReceivedRefounding? {
val entry = entryFor(f.community)
val authority = ConcordCommunityState.fold(f.editions, f.community.communityId, f.community.ownerPubKey).authority
return ConcordActions.openBaseRekey(
wraps = wraps,
baseRekey = ConcordActions.nextBaseRekeyPlane(f.community.communityRoot, f.community.communityId, f.community.rootEpoch),
recipientSigner = member,
communityId = f.community.communityIdHex,
priorRoot = f.community.communityRoot,
rootEpoch = f.community.rootEpoch,
accept = { ConcordReceive.isHonoredRotation(entry, f.editions, authority, it) },
)
}
// ---- I9 ----------------------------------------------------------------------------------
@Test
fun anAdminsRotationIsHonoredOnlyWithItsGrantCited() =
runTest {
val f = withAdmin()
val entry = entryFor(f.community)
val citation = ConcordReceive.rotationCitation(entry, f.editions, admin.pubKey)
assertNotNull(citation, "a BAN-holding admin has a Grant to cite")
assertNull(ConcordReceive.rotationCitation(entry, f.editions, owner.pubKey), "the owner cites nothing")
val root = ByteArray(32) { 0x22 }
assertContentEquals(root, receive(f, rotate(f.community, admin, root, citation))?.newRoot)
assertNull(receive(f, rotate(f.community, admin, root, authority = null)), "an uncited delegated rotation is dropped")
assertContentEquals(root, receive(f, rotate(f.community, owner, root, authority = null))?.newRoot, "the owner needs no citation")
}
@Test
fun aRotationCitingAGrantWeHaveNotSyncedIsParked() =
runTest {
val f = withAdmin()
val real = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)!!
val ahead = AuthorityCitation(real.grantId, real.grantVersion + 1, real.grantHash)
assertNull(receive(f, rotate(f.community, admin, ByteArray(32) { 0x22 }, ahead)))
}
@Test
fun aStrangerCannotRotateEvenCitingSomeonesGrant() =
runTest {
val f = withAdmin()
val stranger = NostrSignerInternal(KeyPair())
val adminsCitation = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)
assertNull(receive(f, rotate(f.community, stranger, ByteArray(32) { 0x22 }, adminsCitation)))
}
// ---- I12 ---------------------------------------------------------------------------------
@Test
fun racingHonoredRotationsConvergeOnTheLowestRoot() =
runTest {
val f = withAdmin()
val citation = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)
val high = ByteArray(32) { 0x70 }
val low = ByteArray(32) { 0x05 }
val wraps = rotate(f.community, owner, high, null) + rotate(f.community, admin, low, citation)
assertContentEquals(low, receive(f, wraps)?.newRoot)
assertContentEquals(low, receive(f, wraps.reversed())?.newRoot)
// An uncited (dishonored) lower root never wins the race.
val rogue = rotate(f.community, admin, ByteArray(32) { 0x01 }, null)
assertContentEquals(high, receive(f, rotate(f.community, owner, high, null) + rogue)?.newRoot)
}
@Test
fun theHealMovesOnlyToAStrictlyLowerSiblingAndKeepsTheLoser() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
val prior = HeldRoot(0, "aa".repeat(32), community.controlPkHex)
val adopted = entryFor(community, root = "70".repeat(32), epoch = 1, heldRoots = listOf(prior))
fun sibling(root: String) = ReceivedRefounding(root.hexToByteArray(), 1, owner.pubKey, ByteArray(32) { 3 }, null)
val healed = ConcordReceive.withHealedRoot(adopted, sibling("05".repeat(32)))
assertNotNull(healed)
assertEquals("05".repeat(32), healed.root)
assertEquals(1L, healed.rootEpoch)
assertEquals(ByteArray(32) { 3 }.toHexKey(), healed.controlPk, "the control pair is the winner's")
assertTrue(healed.heldRoots.any { it.epoch == 1L && it.key == "70".repeat(32) && it.controlPk == null }, "the losing fork's root is kept for its messages")
assertEquals(prior.key, ConcordRefounding.canonicalHeldRoots(healed.heldRoots).first { it.epoch == 0L }.key)
assertNull(ConcordReceive.withHealedRoot(adopted, sibling("90".repeat(32))), "down-only: a higher sibling never re-forks the epoch")
assertNull(ConcordReceive.withHealedRoot(adopted, sibling("70".repeat(32))))
// The next adoption keeps both same-epoch roots instead of collapsing them by epoch.
val next = ConcordReceive.withAdoptedRoot(healed, ByteArray(32) { 9 }, 2)
assertEquals(
setOf("05".repeat(32), "70".repeat(32)),
next.heldRoots
.filter { it.epoch == 1L }
.map { it.key }
.toSet(),
)
assertEquals("05".repeat(32), ConcordRefounding.canonicalHeldRoots(next.heldRoots).first { it.epoch == 1L }.key)
}
@Test
fun aSessionWatchesTheRotationIntoItsOwnEpoch() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
val prior = HeldRoot(community.rootEpoch, community.communityRoot.toHexKey(), community.controlPkHex)
val entry = entryFor(community, root = "70".repeat(32), epoch = community.rootEpoch + 1, heldRoots = listOf(prior))
val sibling = ConcordActions.siblingBaseRekeyPlane(entry)
assertNotNull(sibling)
assertEquals(ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex, sibling.publicKeyHex)
assertNull(ConcordActions.siblingBaseRekeyPlane(entryFor(community)), "a joiner holding no prior root has nothing to watch")
val session = ConcordCommunitySession(entry, member.pubKey)
assertEquals(sibling.publicKeyHex, session.siblingBaseRekeyAddress)
assertTrue(session.ownsPlane(sibling.publicKeyHex))
assertTrue(session.auxStreamKeys().any { it.publicKeyHex == sibling.publicKeyHex })
val wraps = rotate(community, owner, ByteArray(32) { 0x05 }, null)
wraps.forEach { session.ingest(it) }
assertEquals(wraps.map { it.id }.toSet(), session.pendingSiblingRekeyWraps().map { it.id }.toSet())
assertTrue(ConcordSubscriptionPlanner.auxiliaryPlaneSubs(listOf(entry)).any { it.pubKeyHex == sibling.publicKeyHex })
}
@Test
fun aLosingForkRootIsNotFoldedAsAControlPlane() {
val community = "11".repeat(32)
val entry =
ConcordCommunityListEntry(
id = community,
owner = "22".repeat(32),
ownerSalt = "33".repeat(32),
root = "44".repeat(32),
rootEpoch = 2,
heldRoots = listOf(HeldRoot(1, "05".repeat(32)), HeldRoot(1, "70".repeat(32))),
relays = listOf("wss://r.example"),
)
// One Control Plane for epoch 1 (the winner's), plus the current one.
assertEquals(2, ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)).size)
assertFalse(ConcordRefounding.canonicalHeldRoots(entry.heldRoots).any { it.key == "70".repeat(32) })
}
}
@@ -43,17 +43,17 @@ Ranked security > interop > feature inside each group.
| S1 | 02 §9 | Dissolution: no `dissolved_pk` plane, no `eid` binding, spec tombstone (chainless, no `ev`) could not even parse; a vsk-10 Control Plane edition dissolved with no binding check | **fixed** — `ConcordDissolution` (derive, build, verify with `eid == community_id`, 20014 seal, owner author); session + planner subscribe the plane; CLI `amy concord dissolve`; the Control Plane fold no longer reads vsk 10 | | S1 | 02 §9 | Dissolution: no `dissolved_pk` plane, no `eid` binding, spec tombstone (chainless, no `ev`) could not even parse; a vsk-10 Control Plane edition dissolved with no binding check | **fixed** — `ConcordDissolution` (derive, build, verify with `eid == community_id`, 20014 seal, owner author); session + planner subscribe the plane; CLI `amy concord dissolve`; the Control Plane fold no longer reads vsk 10 |
| S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | **fixed** — `ConcordActions.currentChannelPlane`/`historicalChannelPlanes` derive a private channel only from the entry's held `privateChannels` key at its channel epoch (never the root plane); session, planner, plane registry, app verbs and CLI all go through it; a keyless private channel has no plane (`ConcordChannel.keyHeld`/`canPost()` false, composer replaced by a notice, `amy concord send` → `no_channel_key`); held keys adopt in place, and invite-delivered keys are stored on join. Creating private channels stays open (F7) | | S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | **fixed** — `ConcordActions.currentChannelPlane`/`historicalChannelPlanes` derive a private channel only from the entry's held `privateChannels` key at its channel epoch (never the root plane); session, planner, plane registry, app verbs and CLI all go through it; a keyless private channel has no plane (`ConcordChannel.keyHeld`/`canPost()` false, composer replaced by a notice, `amy concord send` → `no_channel_key`); held keys adopt in place, and invite-delivered keys are stored on join. Creating private channels stays open (F7) |
| S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | **fixed** — `ChannelChat.delete` (examples §2.4: binding + `e` per target + `k` per kind) sealed 20013 on the channel plane (`ConcordActions.buildChannelDelete`); `Account.delete`/`deletePrivately` route Concord notes (messages, replies, reactions) to `AccountConcordActions.deleteConcordRumors`, each target on the plane of its bound epoch (Armada always uses the current plane); tapping an own Concord reaction retracts it the same way | | S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | **fixed** — `ChannelChat.delete` (examples §2.4: binding + `e` per target + `k` per kind) sealed 20013 on the channel plane (`ConcordActions.buildChannelDelete`); `Account.delete`/`deletePrivately` route Concord notes (messages, replies, reactions) to `AccountConcordActions.deleteConcordRumors`, each target on the plane of its bound epoch (Armada always uses the current plane); tapping an own Concord reaction retracts it the same way |
| S4 | 06 §2 | Stranded recovery adopts a bundle's newer `community_root` with no continuity or authority check — a link creator can relocate every member who joined through their link | open → rekey/invite batch | | S4 | 06 §2 | Stranded recovery adopts a bundle's newer `community_root` with no continuity or authority check — a link creator can relocate every member who joined through their link | **fixed** — `ConcordStrandedRecovery` only detects (`isStranded`); the background sweep never moves the base (exposes `strandedConcordCommunities`); moving forward from a bundle needs the user to re-open the link (`joinConcordViaInvite`, `amy concord recover --rejoin`). PR #23's accept-time root gate not implemented (text unavailable; genuine owner editions re-wrap into any plane, so it needs the PR's exact rule) |
| S5 | 04 §1 | Grant `eid` never checked against `grant_locator(cid, member)`; a second grant chain at a random coordinate overrides the canonical one, order-dependent | **fixed** — `AuthorityResolver.resolve` / `ConcordCommunityState.fold` / `authorizedHeads` take the `community_id`; a Grant edition counts only when its `eid == grant_locator(cid, content.member)` | | S5 | 04 §1 | Grant `eid` never checked against `grant_locator(cid, member)`; a second grant chain at a random coordinate overrides the canonical one, order-dependent | **fixed** — `AuthorityResolver.resolve` / `ConcordCommunityState.fold` / `authorizedHeads` take the `community_id`; a Grant edition counts only when its `eid == grant_locator(cid, content.member)` |
| S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist `eid` unchecked | **fixed** — the Banlist folds to ONE authority-gated head at `banlist_locator(cid)`; the rank-delta rule is kept; re-heal = the writer re-applying atop the winner (`ConcordModeration.ban` again after refold) | | S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist `eid` unchecked | **fixed** — the Banlist folds to ONE authority-gated head at `banlist_locator(cid)`; the rank-delta rule is kept; re-heal = the writer re-applying atop the winner (`ConcordModeration.ban` again after refold) |
| S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | **fixed** — `EditionFold.foldEntityGated`/`foldGated` take an author rank: authority first, then rumor id, both in the head pick (Armada `pickHead`) and in the walk's anchor/next-link choice. The walk part goes past Armada (whose `version.fold` walks on rumor id only, so a lower-id fork can strand an edition chained on the owner's sibling) — spec followed | | S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | **fixed** — `EditionFold.foldEntityGated`/`foldGated` take an author rank: authority first, then rumor id, both in the head pick (Armada `pickHead`) and in the walk's anchor/next-link choice. The walk part goes past Armada (whose `version.fold` walks on rumor id only, so a lower-id fork can strand an edition chained on the owner's sibling) — spec followed |
| S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | **fixed** — metadata is read only at `eid == community_id` (a fold gate, `AuthorityResolver.isWellFormed`) | | S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | **fixed** — metadata is read only at `eid == community_id` (a fold gate, `AuthorityResolver.isWellFormed`) |
| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | **fixed** — control: `ControlEdition.fromOpened` refuses a non-20014 seal; used by the session, `ConcordActions.controlEditions` (CLI) and Refounding compaction; chat: `ChannelChat.acceptOpened` requires a 20013 seal, a `CHAT_KINDS` rumor (9, 1111, 7, 5, 3302, 23311, 1740), the strict binding and a well-formed `ms`, for stored and typing wraps; `EventCache.consumeConcordRumor` refuses non-chat kinds too | | S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | **fixed** — control: `ControlEdition.fromOpened` refuses a non-20014 seal; used by the session, `ConcordActions.controlEditions` (CLI) and Refounding compaction; chat: `ChannelChat.acceptOpened` requires a 20013 seal, a `CHAT_KINDS` rumor (9, 1111, 7, 5, 3302, 23311, 1740), the strict binding and a well-formed `ms`, for stored and typing wraps; `EventCache.consumeConcordRumor` refuses non-chat kinds too |
| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | **fixed** — `ConcordStreamEnvelope` seal/wrap refuse a plaintext over 65,535 UTF-8 bytes (Armada `encryptChecked`), and open refuses an extended-format payload before decrypting | | S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | **fixed** — `ConcordStreamEnvelope` seal/wrap refuse a plaintext over 65,535 UTF-8 bytes (Armada `encryptChecked`), and open refuses an extended-format payload before decrypting |
| S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | open → rekey/invite batch | | S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | **fixed** — `ConcordInviteBundle.bound`: >256 channels refused, `relays` de-duplicated + truncated to 5, applied in `parse` (link bundles) and `ConcordDirectInvite.parse`; fragments decode ≤3 relays |
| S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | open → rekey/invite batch | | S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | **fixed** — `compactControlPlane(…, mustCarry)` throws `IncompleteControlPlaneException` on a missing honored head; app sweeps the plane paged (majority of relays DRAINED) and CLI pages it; rekey chunks are `publishAndConfirm`ed first, compaction published only after |
| S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | open → rekey/invite batch | | S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | **fixed** — dissolved check in `drainConcordRekeys`, `recoverStrandedConcordCommunities`, `refoundConcordCommunity`, the explicit rejoin, and CLI `rekey`/`recover`/`refound` (`ConcordCommands.isDissolved`) |
| S14 | 05 §2 | `classify` trusts the relay filter: no signature, `pubkey == link_signer`, or `d == ""` check; a relay can forge a revocation | open → rekey/invite batch | | S14 | 05 §2 | `classify` trusts the relay filter: no signature, `pubkey == link_signer`, or `d == ""` check; a relay can forge a revocation | **fixed** — `classify(wraps, linkSignerPubKey, token)` keeps only events with kind 33301, author == link signer, `d == ""` and a valid signature (`isAtCoordinate`); every caller passes the signer |
### Interop (Armada drops or diverges) ### Interop (Armada drops or diverges)
@@ -66,17 +66,17 @@ Ranked security > interop > feature inside each group.
| I5 | 04 §2 | Role content lacks `role_id`; Armada ignores every role we mint | **fixed** — `RoleEntity.roleId` written by `defineRole`; read accepts a legacy role without it, refuses a mismatching one | | I5 | 04 §2 | Role content lacks `role_id`; Armada ignores every role we mint | **fixed** — `RoleEntity.roleId` written by `defineRole`; read accepts a legacy role without it, refuses a mismatching one |
| I6 | 04 §1 | First edition is v0; spec says versions start at 1 | **fixed** — genesis and every new entity start at v1; v0 chains still read | | I6 | 04 §1 | First edition is v0; spec says versions start at 1 | **fixed** — genesis and every new entity start at v1; v0 chains still read |
| I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | **fixed** — a canonical unmodeled `vsk` parses with `entityKind == null` + raw `vsk`; floors and compaction carry its gated head verbatim (11 by `PIN_MESSAGES`, 12 by `MANAGE_CHANNELS`, others by any staff bit). vsk 6/7/9/10 are no longer parsed as Control editions | | I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | **fixed** — a canonical unmodeled `vsk` parses with `entityKind == null` + raw `vsk`; floors and compaction carry its gated head verbatim (11 by `PIN_MESSAGES`, 12 by `MANAGE_CHANNELS`, others by any staff bit). vsk 6/7/9/10 are no longer parsed as Control editions |
| I8 | 06 | Rekey `chunk` index is 0-based; Armada requires 1-based and drops all our Refoundings | open → rekey/invite batch | | I8 | 06 | Rekey `chunk` index is 0-based; Armada requires 1-based and drops all our Refoundings | **fixed** — `ConcordRekey.tags` takes a 1-based index (`require 1..n`); `chunkOf` parses strict decimals and refuses 0 / `i > n`; receivers drop malformed chunks |
| I9 | 06 §3 | Rotations carry no `vac` | open → rekey/invite batch | | 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 | open → rekey/invite batch | | 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 | open → rekey/invite batch | | 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 | open → rekey/invite batch | | I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | **fixed** — `findNewRoot` converges on the lowest authorized root (`accept` filter before `converge`); sessions watch the current epoch's own rekey address and `drainConcordRekeys` heals down-only (`ConcordReceive.withHealedRoot`), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: `canonicalHeldRoots`); retries reuse reserved keys (`ConcordRefounding.reserveKeys`; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys (no private channels yet, F7) |
| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | **fixed** — exactly one `channel` and one `epoch` tag, epoch compared as its canonical decimal string (Armada `uniqueTag`/`checkChannelBinding`); builders drop binding tags smuggled in `extraTags` | | 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 | | 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`) | | I15 | 02 §4 | No `ms` tag on chat rumors | **fixed** — every `ChannelChat` rumor carries `["ms", 0..999]` after the binding; malformed/duplicated `ms` drops the rumor; `channelMessages` and edit recency order by `created_at*1000+ms` (the shared feed still sorts by `created_at`; open PR #7 may drop `ms`) |
| I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | **fixed** — four-element `q` (the `p` credit stays, as Armada keeps it). Also CORD-03 §3: your own kind-1111 thread replies can now be edited (kind 3302) like kind-9 messages | | I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | **fixed** — four-element `q` (the `p` credit stays, as Armada keeps it). Also CORD-03 §3: your own kind-1111 thread replies can now be edited (kind 3302) like kind-9 messages |
| I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | **fixed** — `ConcordLimits`; refused on write (factory, `ConcordModeration`, app verbs, CLI) and enforced at fold like Armada: over-cap role/metadata editions fall back, a Grant's `role_ids` trim to 64, the Community keeps its 100 lowest `role_id`s. Also: `ev`/`vac`/`vsk` must be canonical decimals and a duplicate `vsk`/`eid`/`ev`/`ep`/`vac` invalidates the edition; CLI knows `VIEW_AUDIT_LOG`/`MENTION_EVERYONE`/`PIN_MESSAGES`; the app's Admin role matches Armada's `ADMIN_ALL` | | I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | **fixed** — `ConcordLimits`; refused on write (factory, `ConcordModeration`, app verbs, CLI) and enforced at fold like Armada: over-cap role/metadata editions fall back, a Grant's `role_ids` trim to 64, the Community keeps its 100 lowest `role_id`s. Also: `ev`/`vac`/`vsk` must be canonical decimals and a duplicate `vsk`/`eid`/`ev`/`ep`/`vac` invalidates the edition; CLI knows `VIEW_AUDIT_LOG`/`MENTION_EVERYONE`/`PIN_MESSAGES`; the app's Admin role matches Armada's `ADMIN_ALL` |
| I18 | 05 §1, §4 | Join doesn't echo invite attribution; CLI join publishes no Guestbook Join; Invite List merge lets the patch win; malformed tombstones dropped | open → rekey/invite batch | | I18 | 05 §1, §4 | Join doesn't echo invite attribution; CLI join publishes no Guestbook Join; Invite List merge lets the patch win; malformed tombstones dropped | **fixed** — join echoes `creator_npub`/`label` in the Guestbook Join (mints now set `creator_npub`); `amy concord join` publishes a Guestbook Join; Invite List merge is first-wins per token; untyped tombstones carried as `opaqueTombstones` and still retire their token |
### Features ### Features
@@ -95,6 +95,12 @@ Ranked security > interop > feature inside each group.
## Spec issues to raise upstream ## Spec issues to raise upstream
- CORD-06 §1 counts rekey capacity in blobs ("up to 120 participants per event"), but 120 base
blobs overflow NIP-44's 65,535-byte plaintext once wrapped; the spec should state the byte budget
(Armada uses a 40,960-byte rumor ceiling).
- The rekey blob plaintext is base64-encoded before NIP-44 (signer APIs take strings); unpinned by
the spec, as Armada's own comment notes.
- Rekey seal kind (20013) and the `chunk` indexing base are implied by examples only; worth a MUST.
- 02 §8 and examples §6.2 cite "a dissolution payload (CORD-06 §1)", but CORD-06 defines no - 02 §8 and examples §6.2 cite "a dissolution payload (CORD-06 §1)", but CORD-06 defines no
such payload, and Armada has none. Dangling reference. such payload, and Armada has none. Dangling reference.
- CORD-07 §2 should require a nonce in the 27235 grant (Armada already adds one): two - CORD-07 §2 should require a nonce in the 27235 grant (Armada already adds one): two
@@ -83,6 +83,7 @@ object ConcordDirectInvite {
if (seal !is SealEvent) return null if (seal !is SealEvent) return null
val rumor = seal.unsealOrNull(recipientSigner) ?: return null val rumor = seal.unsealOrNull(recipientSigner) ?: return null
if (rumor.kind != KIND) return null if (rumor.kind != KIND) return null
return ConcordJson.decodeOrNull<CommunityInvite>(rumor.content) // Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply").
return ConcordJson.decodeOrNull<CommunityInvite>(rumor.content)?.let { ConcordInviteBundle.bound(it) }
} }
} }
@@ -26,9 +26,11 @@ import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip44Encryption.Nip44
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
@@ -94,6 +96,52 @@ class MintedInviteLink(
object ConcordInviteBundle { object ConcordInviteBundle {
const val KIND = ConcordInviteBundleEvent.KIND const val KIND = ConcordInviteBundleEvent.KIND
/**
* A bundle naming more Channels than this is refused before anything is allocated for it
* (CORD-05 §1: "reject a bundle carrying more than a sane channel count (Vector's ceiling is
* 256)"). A bundle is attacker-crafted input reached by following a link.
*/
const val MAX_BUNDLE_CHANNELS = 256
/**
* A bundle's `relays` are truncated to the Community's relay cap before anything connects to
* them (CORD-05 §1, CORD-02 §6's "up to 5"): a hostile link must not be a connect storm.
*/
const val MAX_COMMUNITY_RELAYS = 5
/**
* Bounds an attacker-crafted [invite] (CORD-05 §1 MUST): null when it names more than
* [MAX_BUNDLE_CHANNELS] Channels, otherwise the invite with `relays` de-duplicated and
* truncated to [MAX_COMMUNITY_RELAYS]. Every redeem path — link bundle, Direct Invite —
* goes through [validate], which applies this.
*/
fun bound(invite: CommunityInvite): CommunityInvite? {
if (invite.channels.size > MAX_BUNDLE_CHANNELS) return null
val relays =
invite.relays
.filter { it.isNotBlank() }
.distinct()
.take(MAX_COMMUNITY_RELAYS)
return if (relays == invite.relays) invite else invite.copy(relays = relays)
}
/**
* True when [event] is really the bundle coordinate `(33301, linkSigner, d="")` (CORD-05 §2):
* the right kind, authored by [linkSignerPubKey], an empty `d`, and a valid signature. A relay
* filter is a hint, not a proof — a relay (or anyone who can write to one) could otherwise
* serve a forged newer `vsk 9` and revoke a link it never owned.
*/
fun isAtCoordinate(
event: Event,
linkSignerPubKey: HexKey,
): Boolean {
if (event.kind != KIND) return false
if (!event.pubKey.equals(linkSignerPubKey, ignoreCase = true)) return false
val d = event.tags.firstOrNull { it.isNotEmpty() && it[0] == "d" }?.getOrNull(1) ?: ""
if (d != "") return false
return event.verify()
}
private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite)
/** Builds a kind-33301 bundle event carrying [invite], encrypted under [token] and signed by [linkSignerPrivKey]. */ /** Builds a kind-33301 bundle event carrying [invite], encrypted under [token] and signed by [linkSignerPrivKey]. */
@@ -125,7 +173,10 @@ object ConcordInviteBundle {
createdAt: Long, createdAt: Long,
): Event = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)).sign(ConcordInviteBundleEvent.buildRevocation(createdAt)) ): Event = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)).sign(ConcordInviteBundleEvent.buildRevocation(createdAt))
/** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */ /**
* Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle.
* The result is already [bound]ed (CORD-05 §1), so an over-long relay list never reaches a caller.
*/
fun parse( fun parse(
event: Event, event: Event,
token: ByteArray, token: ByteArray,
@@ -133,7 +184,7 @@ object ConcordInviteBundle {
if (event.kind != KIND) return null if (event.kind != KIND) return null
return try { return try {
val bundleKey = ConcordKeyDerivation.inviteBundleKey(token) val bundleKey = ConcordKeyDerivation.inviteBundleKey(token)
ConcordJson.decodeOrNull<CommunityInvite>(Nip44.v2.decrypt(event.content, bundleKey)) ConcordJson.decodeOrNull<CommunityInvite>(Nip44.v2.decrypt(event.content, bundleKey))?.let { bound(it) }
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
@@ -152,11 +203,25 @@ object ConcordInviteBundle {
* milliseconds) resolves to [InviteBundleStatus.Expired] rather than * milliseconds) resolves to [InviteBundleStatus.Expired] rather than
* [InviteBundleStatus.Live], so the expiry is actually enforced at the one place * [InviteBundleStatus.Live], so the expiry is actually enforced at the one place
* every redeeming client already funnels through. * every redeeming client already funnels through.
*
* Only events really at the coordinate count ([isAtCoordinate]: kind, author ==
* [linkSignerPubKey], `d == ""`, valid signature) — the relay filter is not trusted, so a
* forged newer revocation cannot kill a link, nor a forged bundle hijack one.
*/ */
fun classify( fun classify(
wraps: List<Event>, wraps: List<Event>,
linkSignerPubKey: HexKey,
token: ByteArray, token: ByteArray,
nowMs: Long = TimeUtils.nowMillis(), nowMs: Long = TimeUtils.nowMillis(),
): InviteBundleStatus {
val genuine = wraps.filter { isAtCoordinate(it, linkSignerPubKey) }
return classifyGenuine(genuine, token, nowMs)
}
private fun classifyGenuine(
wraps: List<Event>,
token: ByteArray,
nowMs: Long,
): InviteBundleStatus { ): InviteBundleStatus {
val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent
if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked
@@ -197,6 +262,7 @@ object ConcordInviteBundle {
* member. Raise with the Concord/Armada authors before diverging. * member. Raise with the Concord/Armada authors before diverging.
*/ */
fun validate(invite: CommunityInvite): Boolean { fun validate(invite: CommunityInvite): Boolean {
if (invite.channels.size > MAX_BUNDLE_CHANNELS) return false
val owner = invite.owner.hexToByteArrayOrNull() ?: return false val owner = invite.owner.hexToByteArrayOrNull() ?: return false
val salt = invite.ownerSalt.hexToByteArrayOrNull() ?: return false val salt = invite.ownerSalt.hexToByteArrayOrNull() ?: return false
return ConcordKeyDerivation.communityId(owner, salt).toHexKey() == invite.communityId return ConcordKeyDerivation.communityId(owner, salt).toHexKey() == invite.communityId
@@ -62,6 +62,13 @@ object ConcordInviteLink {
const val VERSION = 4 const val VERSION = 4
const val FLAG_STOCK_RELAYS = 0x01 const val FLAG_STOCK_RELAYS = 0x01
/**
* The fragment carries at most this many bootstrap relays (CORD-05 §3): it only has to *find*
* the bundle, which then carries the Community's authoritative relay set. The reference
* client's decoder refuses a longer list, so an encoder must never emit one.
*/
const val MAX_BOOTSTRAP_RELAYS = 3
private const val MARKER_WSS_HOST = 0 private const val MARKER_WSS_HOST = 0
private const val MARKER_FULL_URL = 255 private const val MARKER_FULL_URL = 255
private const val WSS_PREFIX = "wss://" private const val WSS_PREFIX = "wss://"
@@ -69,13 +76,10 @@ object ConcordInviteLink {
/** /**
* Encodes the fragment for [token] and optional [relays]. Passing null or the * Encodes the fragment for [token] and optional [relays]. Passing null or the
* exact stock set uses flag `0x01` and emits no relay bytes; otherwise every * exact stock set uses flag `0x01` and emits no relay bytes (the stock set is
* relay is encoded (dictionary id, `wss://` host, or full URL). * flag-selected, so exempt from the cap); otherwise the first
* * [MAX_BOOTSTRAP_RELAYS] relays are encoded (dictionary id, `wss://` host, or full
* The only ceiling is the format's own: the relay count is a single byte, so at * URL) and the rest dropped (CORD-05 §3) — the bundle carries the full set.
* most 255 relays fit. Each carries its own byte cost, so a long list makes a long
* link — pick relays that can actually serve the bundle rather than pasting a
* whole relay list in.
*/ */
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
fun encodeFragment( fun encodeFragment(
@@ -90,10 +94,10 @@ object ConcordInviteLink {
if (useStock) { if (useStock) {
out.add(FLAG_STOCK_RELAYS.toByte()) out.add(FLAG_STOCK_RELAYS.toByte())
} else { } else {
require(relays.size <= 255) { "relay count must fit in one byte, was ${relays.size}" } val bounded = relays.distinct().take(MAX_BOOTSTRAP_RELAYS)
out.add(0) out.add(0)
out.add(relays.size.toByte()) out.add(bounded.size.toByte())
for (r in relays) { for (r in bounded) {
val id = InviteRelayDictionary.idOf(r) val id = InviteRelayDictionary.idOf(r)
when { when {
id != null -> out.add(id.toByte()) id != null -> out.add(id.toByte())
@@ -119,8 +123,9 @@ object ConcordInviteLink {
} }
/** /**
* Decodes an invite [fragment]. Throws for a malformed fragment or a version * Decodes an invite [fragment]. Throws for a malformed fragment, a version
* other than [VERSION] (lower = legacy, higher = newer than this client). * other than [VERSION] (lower = legacy, higher = newer than this client), or more
* than [MAX_BOOTSTRAP_RELAYS] relays (CORD-05 §3, as the reference client does).
* Unknown dictionary ids are skipped rather than aborting the parse. * Unknown dictionary ids are skipped rather than aborting the parse.
*/ */
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
@@ -138,7 +143,9 @@ object ConcordInviteLink {
relays.addAll(InviteRelayDictionary.STOCK) relays.addAll(InviteRelayDictionary.STOCK)
usedStock = true usedStock = true
} else { } else {
require(pos < bytes.size) { "fragment truncated" }
val count = bytes[pos++].toInt() and 0xFF val count = bytes[pos++].toInt() and 0xFF
require(count <= MAX_BOOTSTRAP_RELAYS) { "too many bootstrap relays ($count, cap $MAX_BOOTSTRAP_RELAYS)" }
repeat(count) { repeat(count) {
val marker = bytes[pos++].toInt() and 0xFF val marker = bytes[pos++].toInt() and 0xFF
when (marker) { when (marker) {
@@ -162,8 +169,9 @@ object ConcordInviteLink {
} }
/** /**
* Builds a full shareable invite URL under [base], carrying every relay in [relays] * Builds a full shareable invite URL under [base], carrying the first
* as the bootstrap set (or the stock flag when null / exactly the stock set). * [MAX_BOOTSTRAP_RELAYS] of [relays] as the bootstrap set (or the stock flag when
* null / exactly the stock set).
*/ */
fun buildUrl( fun buildUrl(
base: String, base: String,
@@ -33,6 +33,7 @@ import kotlinx.serialization.descriptors.elementNames
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonTransformingSerializer import kotlinx.serialization.json.JsonTransformingSerializer
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
@@ -86,12 +87,16 @@ class ConcordInviteListTombstone(
* a newer schema. They are carried verbatim rather than dropped (re-encoding without them would * a newer schema. They are carried verbatim rather than dropped (re-encoding without them would
* delete somebody's `signer_sk`) and rather than failing the whole read (which would refuse every * delete somebody's `signer_sk`) and rather than failing the whole read (which would refuse every
* future mint and revoke for this account until someone else repaired the list). * future mint and revoke for this account until someone else repaired the list).
*
* [opaqueTombstones] is the same for tombstones: one that does not type-check is residue we carry
* verbatim, never drop — dropping a retirement is how a stale device resurrects a revoked link.
*/ */
class ConcordInviteListDocument( class ConcordInviteListDocument(
val entries: List<ConcordInviteListEntry> = emptyList(), val entries: List<ConcordInviteListEntry> = emptyList(),
val tombstones: List<ConcordInviteListTombstone> = emptyList(), val tombstones: List<ConcordInviteListTombstone> = emptyList(),
val residue: JsonObject = NoExtras, val residue: JsonObject = NoExtras,
val opaqueEntries: List<JsonObject> = emptyList(), val opaqueEntries: List<JsonObject> = emptyList(),
val opaqueTombstones: List<JsonElement> = emptyList(),
) { ) {
companion object { companion object {
val EMPTY = ConcordInviteListDocument() val EMPTY = ConcordInviteListDocument()
@@ -201,14 +206,16 @@ object ConcordInviteList {
} }
} }
val opaqueTombstones = mutableListOf<JsonElement>()
val tombstones = val tombstones =
(root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element -> (root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element ->
try { try {
val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject) val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject)
ConcordInviteListTombstone(it.token, it.communityId, it.extras) ConcordInviteListTombstone(it.token, it.communityId, it.extras)
} catch (_: Exception) { } catch (_: Exception) {
// A tombstone we cannot read must not silently un-retire its link, but we // A tombstone we cannot type must not silently un-retire its link: carry it
// have no token to key it by, so it can only ride along as document residue. // verbatim as residue (and still honor its token in the merge, if it has one).
opaqueTombstones.add(element)
null null
} }
} }
@@ -218,6 +225,7 @@ object ConcordInviteList {
tombstones = tombstones, tombstones = tombstones,
residue = JsonObject(root - "entries" - "tombstones"), residue = JsonObject(root - "entries" - "tombstones"),
opaqueEntries = opaque, opaqueEntries = opaque,
opaqueTombstones = opaqueTombstones,
) )
} catch (_: Exception) { } catch (_: Exception) {
null null
@@ -238,30 +246,43 @@ object ConcordInviteList {
), ),
).jsonObject ).jsonObject
// Entries we could not type ride back out untouched. Dropping them here is the data loss // Entries and tombstones we could not type ride back out untouched. Dropping them here is
// this whole class exists to prevent — they are somebody's link signer too. // the data loss this whole class exists to prevent — a link signer, or a link's retirement.
if (doc.opaqueEntries.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire) if (doc.opaqueEntries.isEmpty() && doc.opaqueTombstones.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire)
val entries = JsonArray((wire["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries) var out = wire
return ConcordJson.instance.encodeToString(JsonObject.serializer(), JsonObject(wire + ("entries" to entries))) if (doc.opaqueEntries.isNotEmpty()) {
out = JsonObject(out + ("entries" to JsonArray((out["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries)))
}
if (doc.opaqueTombstones.isNotEmpty()) {
out = JsonObject(out + ("tombstones" to JsonArray((out["tombstones"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueTombstones)))
}
return ConcordJson.instance.encodeToString(JsonObject.serializer(), out)
} }
/** The `token` an untyped tombstone still names, if it names one as a string. */
private fun opaqueTombstoneToken(element: JsonElement): String? = ((element as? JsonObject)?.get("token") as? JsonPrimitive)?.takeIf { it.isString }?.content
/** /**
* Merges [patch] onto [base], keyed by `token` — the spec's own merge key. A token present in * Merges [patch] onto [base], keyed by `token` — the spec's own merge key (CORD-05 §4). An entry
* either side's tombstones is dropped from the result and kept tombstoned, so a retired link * is **immutable once minted**, so the first copy of a token wins ([base], the published list,
* cannot be resurrected by a device that still has it cached. [patch] wins field-by-field on a * before [patch]) and a later copy can never rewrite its `signer_sk` or url; tombstones union
* token both sides carry, which is what makes "read remote, apply my change, publish" converge. * (first wins likewise), and a tombstone always beats an entry — terminally, so a retired link
* cannot be resurrected by a device that still has it cached. Mirrors the reference client's
* `mergeInviteLists`. A tombstone we could not type still retires the token it names.
*/ */
fun merge( fun merge(
base: ConcordInviteListDocument, base: ConcordInviteListDocument,
patch: ConcordInviteListDocument, patch: ConcordInviteListDocument,
): ConcordInviteListDocument { ): ConcordInviteListDocument {
val tombstones = LinkedHashMap<String, ConcordInviteListTombstone>() val tombstones = LinkedHashMap<String, ConcordInviteListTombstone>()
for (t in base.tombstones + patch.tombstones) tombstones[t.token] = t for (t in base.tombstones + patch.tombstones) tombstones.getOrPut(t.token) { t }
val opaqueTombstones = (base.opaqueTombstones + patch.opaqueTombstones).distinct()
val retired = tombstones.keys + opaqueTombstones.mapNotNull { opaqueTombstoneToken(it) }
val entries = LinkedHashMap<String, ConcordInviteListEntry>() val entries = LinkedHashMap<String, ConcordInviteListEntry>()
for (e in base.entries + patch.entries) { for (e in base.entries + patch.entries) {
if (e.token in tombstones) continue if (e.token in retired) continue
entries[e.token] = e entries.getOrPut(e.token) { e }
} }
return ConcordInviteListDocument( return ConcordInviteListDocument(
entries = entries.values.toList(), entries = entries.values.toList(),
@@ -270,6 +291,7 @@ object ConcordInviteList {
// Untyped entries survive the merge for the same reason they survive a decode: we cannot // Untyped entries survive the merge for the same reason they survive a decode: we cannot
// read them, so we are in no position to decide they are disposable. // read them, so we are in no position to decide they are disposable.
opaqueEntries = (base.opaqueEntries + patch.opaqueEntries).distinct(), opaqueEntries = (base.opaqueEntries + patch.opaqueEntries).distinct(),
opaqueTombstones = opaqueTombstones,
) )
} }
} }
@@ -24,38 +24,37 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
/** /**
* Stranded recovery (CORD-05/06). * Stranded detection (CORD-05/06).
* *
* A Refounding carries only `(newRoot, newEpoch, rotator)` — there is **no * A Refounding carries only `(newRoot, newEpoch, rotator)` — there is **no
* recipient list** — so a member who is simply left out of the rekey recipient * recipient list** — so a member who is simply left out of the rekey recipient
* set receives nothing and is silently stranded on the dead epoch forever, while * set receives nothing and is silently stranded on the dead epoch, while everyone
* everyone else moves on. This is true of any member, the owner included, and * else moves on. The invite link the membership was joined through
* cannot be prevented on the receive side. * ([ConcordCommunityListEntry.inviteRef]) is re-minted at the current epoch by its
* creator, so re-resolving it and finding a **higher** epoch tells a member they
* were left behind.
* *
* The way out is the invite link the membership was joined through * What a bundle may NOT do is move the base (CORD-06 §2, and the reference client's
* ([ConcordCommunityListEntry.inviteRef]): the community keeps publishing its * `isCatchUpBundle`: "It may never move the base"). Nothing binds `community_root`
* bundle at that same addressable coordinate, re-minted at the current epoch. So * to `community_id`, so a bundle is not proof of continuity: a link creator — or
* a member who re-resolves their own join link and finds a **higher** epoch than * anyone who ever held a link's signer — could serve a higher-epoch bundle carrying
* the one they hold knows they were left behind, and can merge forward. * a root of their own and silently relocate every member who joined through that
* * link onto streams they read. The base advances only by a CORD-06 §2 rekey blob
* This object holds only the pure decision + merge; fetching and unlocking the * whose `prevcommit` proves it extends the key we hold, from a rotator our roster
* bundle at the link is the caller's job. * authorizes. So this object only **detects** ([isStranded]); the background sweep
* never adopts anything, and the one way forward from a bundle is the user
* explicitly accepting the link again ([rejoinForward]) — the same trust decision
* as the first join, never taken on their behalf.
*/ */
object ConcordStrandedRecovery { object ConcordStrandedRecovery {
/** /**
* True when [bundle], resolved at [entry]'s stored invite link, proves we were * True when [bundle], resolved at [entry]'s stored invite link, says we were
* left behind: it must describe the same community and sit at a strictly higher * left behind: it must describe the same community and sit at a strictly higher
* epoch. Same or lower is a no-op (we are current, or the bundle is stale). * epoch. Same or lower is a no-op (we are current, or the bundle is stale).
* *
* [bannedAtCurrentEpoch] is the caller's answer to "does the community, as I fold * [bannedAtCurrentEpoch] is the caller's answer to "does the community, as I fold
* it right now, have me on its banlist?" — and a `true` refuses the recovery * it right now, have me on its banlist?" — and a `true` answers false outright:
* outright. It is a required argument rather than a caller-side `if` because * a removed member is not stranded, they are removed.
* getting it wrong turns this mechanism inside out: recovery exists so a member
* *wrongly* omitted from a rotation can catch up, but the test it performs (a
* higher epoch at a link whose unlock token an ex-member keeps forever) cannot
* tell that member apart from one the community deliberately removed. Without
* this, a Refounding — the only hard removal Concord has — is undone by our own
* background sweep a few minutes later.
*/ */
fun isStranded( fun isStranded(
entry: ConcordCommunityListEntry, entry: ConcordCommunityListEntry,
@@ -68,21 +67,18 @@ object ConcordStrandedRecovery {
bundle.rootEpoch > entry.rootEpoch bundle.rootEpoch > entry.rootEpoch
/** /**
* Merges [entry] forward onto the higher-epoch [bundle], or returns null when * The entry that results from the user **explicitly** re-accepting the invite
* there is nothing to do ([isStranded] is false) — so the caller can treat null * link [bundle] was resolved from, while stranded on [entry] — or null when
* as "stay put" without a second check. * [isStranded] is false. Never call this from a background sweep: adopting a
* bundle's root is a join decision (see the class note), and only the user can
* make it.
* *
* The merge is epoch-monotonic (it never moves backwards, by construction of * The merge is epoch-monotonic and preserves what a fresh join would lose: the
* [isStranded]) and preserves two things the naive "adopt the bundle" would * [ConcordCommunityListEntry.inviteRef] anchor, and the existing
* destroy: * [ConcordCommunityListEntry.heldRoots] plus the root we are leaving, so
* * prior-epoch history stays derivable.
* - the [ConcordCommunityListEntry.inviteRef] anchor, so the next Refounding we
* are left out of is recoverable too; and
* - the existing [ConcordCommunityListEntry.heldRoots], plus the root we are
* leaving, so prior-epoch history the member legitimately holds stays
* derivable instead of going dark on catch-up.
*/ */
fun mergeForward( fun rejoinForward(
entry: ConcordCommunityListEntry, entry: ConcordCommunityListEntry,
bundle: CommunityInvite, bundle: CommunityInvite,
bannedAtCurrentEpoch: Boolean, bannedAtCurrentEpoch: Boolean,
@@ -92,7 +88,7 @@ object ConcordStrandedRecovery {
// Bank the epoch we are leaving with its control_pk, so its Control Plane // Bank the epoch we are leaving with its control_pk, so its Control Plane
// stays re-subscribable for the anti-rollback floor (a split epoch's address // stays re-subscribable for the anti-rollback floor (a split epoch's address
// is held, never derivable — CORD-02 §2). // is held, never derivable — CORD-02 §2).
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch } val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch to it.key.lowercase() }
return ConcordCommunityListEntry( return ConcordCommunityListEntry(
id = entry.id, id = entry.id,
@@ -109,10 +105,8 @@ object ConcordStrandedRecovery {
name = entry.name.ifEmpty { bundle.name }, name = entry.name.ifEmpty { bundle.name },
addedAt = entry.addedAt, addedAt = entry.addedAt,
inviteRef = entry.inviteRef, inviteRef = entry.inviteRef,
// We were excluded from the epoch we were sitting on when we found the gap.
excludedAtEpoch = entry.rootEpoch,
// Unknown keys another client wrote are data we hold in trust: carry them forward, // Unknown keys another client wrote are data we hold in trust: carry them forward,
// or this recovery write silently deletes them. // or this write silently deletes them.
residue = entry.residue, residue = entry.residue,
) )
} }
@@ -21,7 +21,10 @@
package com.vitorpamplona.quartz.concord.cord06Rekey package com.vitorpamplona.quartz.concord.cord06Rekey
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.crypto.GroupKey
@@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.RandomInstance
/** /**
* The events a Refounding produces (CORD-06 §3): the [controlWraps] (the current * The events a Refounding produces (CORD-06 §3): the [controlWraps] (the current
@@ -74,11 +78,51 @@ class ReceivedRefounding(
val rotator: HexKey, val rotator: HexKey,
val newControlPk: ByteArray? = null, val newControlPk: ByteArray? = null,
val newControlRoot: ByteArray? = null, val newControlRoot: ByteArray? = null,
/**
* The Grant the rotator claims to act under (`vac`, CORD-06 §3 "Authority"), null when absent
* (the owner cites nothing). The caller verifies it against its fold before adopting — see
* [ConcordRotationAuthority.citationSatisfied].
*/
val authority: AuthorityCitation? = null,
) { ) {
/** True when this was a legacy pre-split rotation (72-byte base blob). */ /** True when this was a legacy pre-split rotation (72-byte base blob). */
val legacy: Boolean get() = newControlPk == null val legacy: Boolean get() = newControlPk == null
} }
/**
* A Refounding the rotator has already minted keys for (CORD-06 §3 "Failure and races"): the
* fresh [newRoot] + [newControlRoot] reserved for the rotation from ([rootEpoch], [prevCommit]).
* A retry of the same rotation MUST reuse them — rotations correlate by (rotator, newepoch,
* prevcommit), so a retry with a fresh root would merge into the first attempt's set and split
* the members across two roots at one epoch. Keep it until the rotation is adopted, then drop it.
*/
class PendingRefounding(
val communityId: HexKey,
val rootEpoch: Long,
val prevCommit: HexKey,
val newRoot: ByteArray,
val newControlRoot: ByteArray,
) {
/** True when this reservation is for the rotation that leaves [priorRoot] at [rootEpoch]. */
fun matches(
communityId: HexKey,
rootEpoch: Long,
priorRoot: ByteArray,
): Boolean =
this.communityId.equals(communityId, ignoreCase = true) &&
this.rootEpoch == rootEpoch &&
prevCommit == ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
}
/**
* Thrown when a Refounding cannot carry the whole Control Plane into the new epoch (CORD-06 §3:
* "If the Refounder cannot reliably fold all Control events, the Refounding must be aborted").
* [missing] names the entities (`eid` hex) whose honored head is not in the compaction.
*/
class IncompleteControlPlaneException(
val missing: List<String>,
) : IllegalStateException("Refounding aborted: the Control Plane could not be folded in full (${missing.size} entity head(s) missing)")
/** /**
* Whole-community Refounding (CORD-06 §3): rotate `community_root` to sever a * Whole-community Refounding (CORD-06 §3): rotate `community_root` to sever a
* removed member absolutely. Public Channels and the Control/Guestbook planes all * removed member absolutely. Public Channels and the Control/Guestbook planes all
@@ -111,6 +155,11 @@ object ConcordRefounding {
* @param recipientsXOnly the retained members' x-only pubkeys (hex) to re-key * @param recipientsXOnly the retained members' x-only pubkeys (hex) to re-key
* @param staffXOnly the subset of [recipientsXOnly] that is staff (owner + Control-writing * @param staffXOnly the subset of [recipientsXOnly] that is staff (owner + Control-writing
* permission holders, CORD-04 §3) and receives the 136-byte blob * permission holders, CORD-04 §3) and receives the 136-byte blob
* @param authority the rotator's `vac` citation (CORD-06 §3 "Authority"), stamped on every
* rekey chunk; null when the owner rotates
* @param mustCarry the entity heads (`eid` hex -> version) the rotator currently honors; the
* compaction must carry each at or above that version, or the Refounding
* aborts with [IncompleteControlPlaneException] (CORD-06 §3)
*/ */
suspend fun build( suspend fun build(
rotatorSigner: NostrSigner, rotatorSigner: NostrSigner,
@@ -125,11 +174,15 @@ object ConcordRefounding {
staffXOnly: Set<HexKey>, staffXOnly: Set<HexKey>,
createdAt: Long, createdAt: Long,
ownerPubKey: HexKey, ownerPubKey: HexKey,
authority: AuthorityCitation? = null,
mustCarry: Map<String, Long> = emptyMap(),
): RefoundingBuild { ): RefoundingBuild {
val newEpoch = rootEpoch + 1 val newEpoch = rootEpoch + 1
val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot) val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot)
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, communityId, ownerPubKey) // Acquired in full BEFORE anything is published (CORD-06 §3): throws when the plane cannot be
// carried whole, so a failed fold never leaves a half rotation as the only copy.
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, communityId, ownerPubKey, mustCarry)
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch) val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch)
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
@@ -146,6 +199,7 @@ object ConcordRefounding {
prevEpoch = rootEpoch, prevEpoch = rootEpoch,
prevCommit = prevCommit, prevCommit = prevCommit,
createdAt = createdAt, createdAt = createdAt,
authority = authority,
) )
return RefoundingBuild(newRoot, newControlRoot, newEpoch, newControlKeys, controlWraps, rekeyWraps) return RefoundingBuild(newRoot, newControlRoot, newEpoch, newControlKeys, controlWraps, rekeyWraps)
@@ -177,6 +231,7 @@ object ConcordRefounding {
newControlKeys: ControlPlaneKeys, newControlKeys: ControlPlaneKeys,
communityId: ByteArray, communityId: ByteArray,
ownerPubKey: HexKey, ownerPubKey: HexKey,
mustCarry: Map<String, Long> = emptyMap(),
): List<Event> { ): List<Event> {
// entity coordinate -> every edition we can open, paired with its verified seal. // entity coordinate -> every edition we can open, paired with its verified seal.
val byCoordinate = HashMap<String, MutableList<Pair<ControlEdition, Event>>>() val byCoordinate = HashMap<String, MutableList<Pair<ControlEdition, Event>>>()
@@ -205,19 +260,46 @@ object ConcordRefounding {
val editions = byCoordinate.values.flatten() val editions = byCoordinate.values.flatten()
val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, communityId, ownerPubKey) val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, communityId, ownerPubKey)
val out = ArrayList<Event>(honored.size) val out = ArrayList<Event>(honored.size)
for ((_, floor) in honored) { val missing = ArrayList<String>()
val head = floor.known ?: continue for ((entity, floor) in honored) {
val seal = editions.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue val head = floor.known
val seal = head?.let { h -> editions.firstOrNull { it.first.rumorId == h.rumorId }?.second }
if (seal == null) {
// A head we honor whose signed seal we cannot re-wrap would silently drop the entity
// from the new epoch: abort instead (fold-all-or-abort, CORD-06 §3).
missing.add(entity)
continue
}
out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt)) out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt))
} }
// Every head the caller already folds must survive at or above the version it honors: a
// shorter compaction means the fetch we compacted from was partial, and publishing it would
// roll the community back for every member who follows the new epoch.
for ((entity, version) in mustCarry) {
val carried = honored[entity]?.known?.version
if (carried == null || carried < version) missing.add(entity)
}
if (missing.isNotEmpty()) throw IncompleteControlPlaneException(missing.distinct())
return out return out
} }
/**
* The version of every entity head [editions] honor (`eid` hex -> version) — what a
* Refounder passes as `mustCarry`, so the compaction aborts rather than drop a head the
* Refounder itself folds (CORD-06 §3).
*/
fun headVersions(
editions: Collection<ControlEdition>,
communityId: ByteArray,
ownerPubKey: HexKey,
): Map<String, Long> = ConcordCommunityState.authorizedHeads(editions, communityId, ownerPubKey).mapValues { it.value.version }
/** /**
* Mints the base-rotation rekey blobs delivering [newRoot] + [newControlPk] to * Mints the base-rotation rekey blobs delivering [newRoot] + [newControlPk] to
* [recipientsXOnly] — the [staffXOnly] subset also receiving [newControlRoot] * [recipientsXOnly] — the [staffXOnly] subset also receiving [newControlRoot]
* in the 136-byte staff form (CORD-06 §1) — chunked at * in the 136-byte staff form (CORD-06 §1) — chunked by count and bytes
* [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal, * ([ConcordRekey.chunkBlobs], 1-based `chunk` tags, [authority] cited on every
* chunk) and wrapped (encrypted seal,
* rotator-signed) on the [baseRekeyKey] address so every current member — who * rotator-signed) on the [baseRekeyKey] address so every current member — who
* precomputes that address from the prior root — receives it live. * precomputes that address from the prior root — receives it live.
*/ */
@@ -233,6 +315,7 @@ object ConcordRefounding {
prevEpoch: Long, prevEpoch: Long,
prevCommit: HexKey, prevCommit: HexKey,
createdAt: Long, createdAt: Long,
authority: AuthorityCitation? = null,
): List<Event> { ): List<Event> {
if (recipientsXOnly.isEmpty()) return emptyList() if (recipientsXOnly.isEmpty()) return emptyList()
val staffLower = staffXOnly.mapTo(HashSet()) { it.lowercase() } val staffLower = staffXOnly.mapTo(HashSet()) { it.lowercase() }
@@ -248,10 +331,19 @@ object ConcordRefounding {
newControlRoot = if (recipient.lowercase() in staffLower) newControlRoot else null, newControlRoot = if (recipient.lowercase() in staffLower) newControlRoot else null,
) )
} }
val chunks = blobs.chunked(ConcordRekey.MAX_BLOBS_PER_CHUNK) // The envelope is measured once at the widest `chunk` tag this rotation could carry.
val widest = blobs.size.coerceAtLeast(1)
val envelopeTags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, widest, widest, authority)
val envelope =
RumorAssembler
.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, envelopeTags, "")
.toJson()
.encodeToByteArray()
.size
val chunks = ConcordRekey.chunkBlobs(blobs, envelope)
val total = chunks.size val total = chunks.size
return chunks.mapIndexed { index, chunk -> return chunks.mapIndexed { index, chunk ->
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index, total) val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index + 1, total, authority)
val rumor = RumorAssembler.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk)) val rumor = RumorAssembler.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk))
ConcordStreamEnvelope.wrap(rumor, baseRekeyKey, rotatorSigner, encrypted = true, createdAt = createdAt) ConcordStreamEnvelope.wrap(rumor, baseRekeyKey, rotatorSigner, encrypted = true, createdAt = createdAt)
} }
@@ -260,14 +352,25 @@ object ConcordRefounding {
/** /**
* Receives a base rotation for the member behind [recipientSigner]: opens the * Receives a base rotation for the member behind [recipientSigner]: opens the
* kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]), * kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]),
* verifies each is a well-formed root rotation to [newEpoch] whose `prevcommit` * verifies each is a well-formed root rotation to `rootEpoch + 1` whose
* continues the [priorRoot] the member holds, and returns the delivered new * `prevepoch`/`prevcommit` continue the [priorRoot] the member holds, and returns
* root and Control Plane keys (with the rotator's real pubkey, so the caller * the delivered new root and Control Plane keys with the rotator's real pubkey and
* can authorize it against the folded roster). A staff blob's delivered secret * `vac` citation, so the caller can authorize it against the folded roster.
* must derive to exactly the delivered `control_pk` (CORD-02 §5) — a *
* mismatched pair is refused rather than adopting a plane split from its * A rekey rumor must ride an **encrypted** (20013) seal (CORD-02 §5, Appendix B); a
* readers. Null if no chunk carries this member's blob — which only means * malformed `chunk` tag (0-based, `i > n`, non-decimal) or `vac` tag drops the
* "removed" once the caller confirms it holds every chunk of the rotation. * chunk, and a rotator whose chunks cite different Grants is distrusted whole. A
* staff blob's delivered secret must derive to exactly the delivered `control_pk`
* (CORD-02 §5) — a mismatched pair is refused rather than adopting a plane split
* from its readers.
*
* Race convergence (CORD-06 §3): among the candidates [accept] admits (the
* caller's authority check — authorize BEFORE converging, or an unauthorized
* lower root would win), the lexicographically lowest new base key wins; the
* control pair rides the winner's blob and is never compared.
*
* Null if no chunk carries this member's blob — which only means "removed" once
* the caller confirms it holds every chunk of the rotation.
*/ */
suspend fun findNewRoot( suspend fun findNewRoot(
wraps: List<Event>, wraps: List<Event>,
@@ -276,31 +379,138 @@ object ConcordRefounding {
communityId: ByteArray, communityId: ByteArray,
priorRoot: ByteArray, priorRoot: ByteArray,
rootEpoch: Long, rootEpoch: Long,
): ReceivedRefounding? { accept: (ReceivedRefounding) -> Boolean = { true },
): ReceivedRefounding? = converge(findNewRoots(wraps, baseRekeyKey, recipientSigner, communityId, priorRoot, rootEpoch).filter(accept))
/**
* Every candidate rotation delivering this member a new root from [priorRoot] at
* [rootEpoch] (one per rotator; see [findNewRoot] for the checks), unauthorized and
* unconverged. Callers normally want [findNewRoot].
*/
suspend fun findNewRoots(
wraps: List<Event>,
baseRekeyKey: GroupKey,
recipientSigner: NostrSigner,
communityId: ByteArray,
priorRoot: ByteArray,
rootEpoch: Long,
): List<ReceivedRefounding> {
val newEpoch = rootEpoch + 1 val newEpoch = rootEpoch + 1
val expectedScope = ConcordRekey.ROOT_SCOPE.toHexKey() val expectedScope = ConcordRekey.ROOT_SCOPE.toHexKey()
val expectedCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() val expectedCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
// Pass 1: the well-formed chunks of this continuity point, grouped by rotator.
val byRotator = LinkedHashMap<HexKey, MutableList<RekeyChunk>>()
for (wrap in wraps) { for (wrap in wraps) {
val opened = ConcordStreamEnvelope.openOrNull(wrap, baseRekeyKey) ?: continue val opened = ConcordStreamEnvelope.openOrNull(wrap, baseRekeyKey) ?: continue
// Rekey seals are encrypted (CORD-02 §5): a plaintext seal would expose the rotator.
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) continue
val rumor = opened.rumor val rumor = opened.rumor
if (rumor.kind != ConcordRekey.KIND) continue if (rumor.kind != ConcordRekey.KIND) continue
if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE) != expectedScope) continue if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE)?.lowercase() != expectedScope) continue
if (rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)?.toLongOrNull() != newEpoch) continue if (rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)?.toLongOrNull() != newEpoch) continue
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT) != expectedCommit) continue if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVEPOCH)?.toLongOrNull() != rootEpoch) continue
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT)?.lowercase() != expectedCommit) continue
val blobs = ConcordRekey.decodeContent(rumor.content) if (ConcordRekey.chunkOf(rumor.tags) == null) continue
val rotatorXOnly = opened.author.hexToByteArray() // A present-but-malformed citation is a corrupt chunk; absent means the owner acts.
val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue val vacTag = rumor.tags.firstOrNull { it.isNotEmpty() && it[0] == VacTag.TAG_NAME }
val controlRoot = payload.newControlRoot val citation = if (vacTag == null) null else VacTag.parse(vacTag) ?: continue
val controlPk = payload.newControlPk byRotator.getOrPut(opened.author.lowercase()) { ArrayList() }.add(RekeyChunk(opened.author, rumor.content, citation))
if (controlRoot != null && controlPk != null) {
// The staff derive-check (CORD-06 §1): refuse a pair whose secret does not
// derive to the pk the other members were handed — fails closed.
val derived = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, newEpoch).publicKey
if (!derived.contentEquals(controlPk)) continue
}
return ReceivedRefounding(payload.newKey, newEpoch, opened.author, controlPk, controlRoot)
} }
return null
// Pass 2: per rotator, find this member's blob.
val out = ArrayList<ReceivedRefounding>()
for ((_, chunks) in byRotator) {
// Every chunk of one rotation must cite the same Grant; a disagreeing set is distrusted.
val citations = chunks.map { c -> c.citation?.let { VacTag.assemble(it).joinToString(",") } }.distinct()
if (citations.size > 1) continue
val rotator = chunks.first().rotator
val rotatorXOnly = rotator.hexToByteArray()
for (chunk in chunks) {
val blobs = ConcordRekey.decodeContent(chunk.content)
val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue
val controlRoot = payload.newControlRoot
val controlPk = payload.newControlPk
if (controlRoot != null && controlPk != null) {
// The staff derive-check (CORD-06 §1): refuse a pair whose secret does not
// derive to the pk the other members were handed — fails closed.
val derived = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, newEpoch).publicKey
if (!derived.contentEquals(controlPk)) continue
}
out.add(ReceivedRefounding(payload.newKey, newEpoch, rotator, controlPk, controlRoot, chunk.citation))
break
}
}
return out
}
private class RekeyChunk(
val rotator: HexKey,
val content: String,
val citation: AuthorityCitation?,
)
/**
* The winner among authorized candidates racing to one epoch (CORD-06 §3): the
* lexicographically lowest new base key. Every client computes the same winner, so
* concurrent Refoundings converge; null on no candidates.
*/
fun converge(candidates: List<ReceivedRefounding>): ReceivedRefounding? = candidates.minWithOrNull { a, b -> compareKeys(a.newRoot, b.newRoot) }
/** Unsigned lexicographic order of two keys — the order the convergence rule compares in. */
fun compareKeys(
a: ByteArray,
b: ByteArray,
): Int {
for (i in 0 until minOf(a.size, b.size)) {
val x = a[i].toInt() and 0xFF
val y = b[i].toInt() and 0xFF
if (x != y) return x - y
}
return a.size - b.size
}
/**
* The down-only heal (CORD-06 §3): true when [candidate] should replace the [held] root
* of the same epoch — only a **strictly lower** sibling does, so a flaky fetch that
* returns only the higher sibling can never re-fork a settled epoch.
*/
fun healsTo(
held: ByteArray,
candidate: ByteArray,
): Boolean = compareKeys(candidate, held) < 0
/**
* The held roots a client folds Control from: per epoch, the lowest key — the one the
* convergence rule settled on. A higher sibling at the same epoch is a losing fork's root,
* kept only so the messages sent into that fork stay readable (CORD-06 §3: "Both forks'
* keys are retained"); its Control Plane is not the community's.
*/
fun canonicalHeldRoots(heldRoots: List<HeldRoot>): List<HeldRoot> =
heldRoots
.groupBy { it.epoch }
.values
.mapNotNull { sameEpoch -> sameEpoch.minWithOrNull { a, b -> a.key.lowercase().compareTo(b.key.lowercase()) } }
/**
* The keys for the Refounding that leaves [priorRoot] at [rootEpoch]: [pending] when it
* was reserved for exactly this rotation (a retry — CORD-06 §3 requires every step to be
* idempotent, so a retry must re-deliver the SAME root), a fresh pair otherwise. The
* caller persists the result before publishing anything and drops it once adopted.
*/
fun reserveKeys(
pending: PendingRefounding?,
communityId: HexKey,
rootEpoch: Long,
priorRoot: ByteArray,
): PendingRefounding {
if (pending != null && pending.matches(communityId, rootEpoch, priorRoot)) return pending
return PendingRefounding(
communityId = communityId.lowercase(),
rootEpoch = rootEpoch,
prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey(),
newRoot = RandomInstance.bytes(32),
newControlRoot = RandomInstance.bytes(32),
)
} }
} }
@@ -20,7 +20,9 @@
*/ */
package com.vitorpamplona.quartz.concord.cord06Rekey package com.vitorpamplona.quartz.concord.cord06Rekey
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -85,7 +87,13 @@ object ConcordRekey {
return RekeyBlob(locator, wrapped) return RekeyBlob(locator, wrapped)
} }
/** The kind-3303 rumor tags for a rekey chunk. */ /**
* The kind-3303 rumor tags for a rekey chunk. [chunkIndex] is **1-based** (CORD-06 §2's
* "chunk i of n"; the reference client refuses an index below 1, so a 0-based chunk makes the
* whole rotation unreadable to it). [authority] is the rotator's `vac` citation (CORD-06 §3
* "Authority", CORD-04 §5), carried on EVERY chunk so a partial holder can judge authority;
* null when the owner rotates.
*/
fun tags( fun tags(
scopeId: ByteArray, scopeId: ByteArray,
newEpoch: Long, newEpoch: Long,
@@ -93,14 +101,78 @@ object ConcordRekey {
prevCommit: HexKey, prevCommit: HexKey,
chunkIndex: Int, chunkIndex: Int,
chunkTotal: Int, chunkTotal: Int,
): Array<Array<String>> = authority: AuthorityCitation? = null,
arrayOf( ): Array<Array<String>> {
arrayOf(TAG_SCOPE, scopeId.toHexKey()), require(chunkTotal >= 1 && chunkIndex in 1..chunkTotal) { "chunk must be 1..n, was $chunkIndex of $chunkTotal" }
arrayOf(TAG_NEWEPOCH, newEpoch.toString()), val base =
arrayOf(TAG_PREVEPOCH, prevEpoch.toString()), arrayOf(
arrayOf(TAG_PREVCOMMIT, prevCommit), arrayOf(TAG_SCOPE, scopeId.toHexKey()),
arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()), arrayOf(TAG_NEWEPOCH, newEpoch.toString()),
) arrayOf(TAG_PREVEPOCH, prevEpoch.toString()),
arrayOf(TAG_PREVCOMMIT, prevCommit),
arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()),
)
return if (authority == null) base else base + arrayOf(VacTag.assemble(authority))
}
/** Strict decimal (`0|[1-9][0-9]*`): digits only, no sign, exponent, radix prefix, padding or leading zero. */
private fun strictDecimal(value: String?): Int? {
if (value.isNullOrEmpty() || value.length > 9 || !value.all { it in '0'..'9' }) return null
if (value.length > 1 && value[0] == '0') return null
return value.toInt()
}
/**
* The `["chunk", i, n]` position of a kind-3303 rumor as a 1-based `(i, n)` pair, or null when
* the tag is malformed: non-decimal, `i < 1`, `n < 1` or `i > n` (a 0-based chunk included).
* An absent tag reads as the single chunk `(1, 1)`, as the reference client does.
*/
fun chunkOf(tags: Array<Array<String>>): Pair<Int, Int>? {
val tag = tags.firstOrNull { it.isNotEmpty() && it[0] == TAG_CHUNK } ?: return 1 to 1
val index = strictDecimal(tag.getOrNull(1)) ?: return null
val count = strictDecimal(tag.getOrNull(2)) ?: return null
if (index < 1 || count < 1 || index > count) return null
return index to count
}
/**
* Splits [blobs] into chunks that each fit one kind-3303 rumor: at most
* [MAX_BLOBS_PER_CHUNK] blobs AND a rumor JSON of at most [REKEY_RUMOR_MAX_BYTES], given the
* [envelopeBytes] the rumor costs with an empty content (measure it at the widest `chunk` tag
* the rotation can carry — over-reserving only makes chunks smaller). Mirrors the reference
* client's budget byte for byte: the content's own `[]`, one comma between blobs, and each
* blob at its JSON-escaped length inside the content string. A lone over-budget blob is kept
* (blobs are indivisible). Always yields at least one (possibly empty) chunk.
*/
fun chunkBlobs(
blobs: List<RekeyBlob>,
envelopeBytes: Int,
): List<List<RekeyBlob>> {
val budget = REKEY_RUMOR_MAX_BYTES - envelopeBytes
val chunks = ArrayList<List<RekeyBlob>>()
var current = ArrayList<RekeyBlob>()
var used = 2 // the content's own "[]"
for (blob in blobs) {
val cost = escapedJsonLength(blob)
if (current.isNotEmpty() && (current.size >= MAX_BLOBS_PER_CHUNK || used + 1 + cost > budget)) {
chunks.add(current)
current = ArrayList()
used = 2
}
used += cost + (if (current.isNotEmpty()) 1 else 0)
current.add(blob)
}
if (current.isNotEmpty() || chunks.isEmpty()) chunks.add(current)
return chunks
}
/** A blob's byte length inside the rumor's JSON-escaped `content` string (without outer quotes). */
private fun escapedJsonLength(blob: RekeyBlob): Int {
val raw = encodeContent(listOf(blob)).let { it.substring(1, it.length - 1) }
var extra = 0
for (c in raw) if (c == '"' || c == '\\') extra++
return raw.encodeToByteArray().size + extra
}
/** Serializes a chunk's blobs into the kind-3303 rumor content. */ /** Serializes a chunk's blobs into the kind-3303 rumor content. */
fun encodeContent(blobs: List<RekeyBlob>): String = ConcordJson.instance.encodeToString(ListSerializer(RekeyBlob.serializer()), blobs) fun encodeContent(blobs: List<RekeyBlob>): String = ConcordJson.instance.encodeToString(ListSerializer(RekeyBlob.serializer()), blobs)
@@ -118,6 +190,16 @@ object ConcordRekey {
/** CORD-06 §1: a single kind-3303 event carries at most this many per-recipient blobs. */ /** CORD-06 §1: a single kind-3303 event carries at most this many per-recipient blobs. */
const val MAX_BLOBS_PER_CHUNK = 120 const val MAX_BLOBS_PER_CHUNK = 120
/**
* Byte ceiling on one kind-3303 rumor's JSON, beside the 120-blob count cap. Base blobs are
* wider than channel ones, and 120 of them pushed the wrap's NIP-44 plaintext (the seal, which
* carries the rumor's own NIP-44 ciphertext as base64) past the 65,535-byte cap. 40,960 is the
* top of the NIP-44 padding bucket that still wraps — the reference client's
* `REKEY_RUMOR_MAX_BYTES`. Capacity: 120 blobs at 72 bytes (the count cap binds), 99 at 104,
* 90 at 136. Finer chunking is always wire-legal.
*/
const val REKEY_RUMOR_MAX_BYTES = 40_960
/** /**
* Builds a rekey blob for one recipient using [rotatorSigner] instead of a raw * Builds a rekey blob for one recipient using [rotatorSigner] instead of a raw
* private key, so a NIP-46 bunker rotator can mint blobs without exposing its * private key, so a NIP-46 bunker rotator can mint blobs without exposing its
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord06Rekey
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
/**
* The `vac` authority citation on a rotation (CORD-06 §3 "Authority", CORD-04 §5): a rotation
* cites the Grant its rotator acts under like any authority action, so a just-demoted admin's
* rotation is never honored by a lagging client. The owner cites nothing — the `community_id`
* proves them.
*
* The citation is a *sync floor*, not the verdict: a verifier refuses the rotation until it
* holds at least the cited Grant edition, then resolves the rotator's rank against its current
* roster (the caller's `hasPermission(BAN)` check). Mirrors the reference client's
* `citationSatisfied`: a newer Grant head than the cited one satisfies, the same version must
* match the edition hash (a fork is refused), an older head parks the rotation (fail closed).
*
* [heads] is the authority-gated head of every Control entity keyed by `eid` hex — exactly
* [ConcordCommunityState.authorizedHeads] over the current epoch's editions ([headsOf]).
*/
object ConcordRotationAuthority {
/** The authority-gated entity heads a citation is minted from and checked against. */
fun headsOf(
editions: Collection<ControlEdition>,
communityIdHex: HexKey,
ownerPubKey: HexKey,
): Map<String, EntityFloor> = ConcordCommunityState.authorizedHeads(editions, communityIdHex.hexToByteArray(), ownerPubKey)
/**
* The citation [actor] puts on a rotation: their own Grant's current head, or null for the
* owner (who cites nothing) and for an actor with no Grant (whose rotation nobody honors).
*/
fun citationFor(
communityIdHex: HexKey,
actor: HexKey,
ownerPubKey: HexKey,
heads: Map<String, EntityFloor>,
): AuthorityCitation? {
if (actor.equals(ownerPubKey, ignoreCase = true)) return null
val eid = ConcordKeyDerivation.grantCoordinate(communityIdHex.hexToByteArray(), actor.lowercase().hexToByteArray())
val head = heads[eid.toHexKey()] ?: return null
val hash = head.hashHex.hexToByteArrayOrNull() ?: return null
return AuthorityCitation(eid, head.version, hash)
}
/** Whether [citation] authorizes [actor]'s rotation under the verifier's [heads]. */
fun citationSatisfied(
communityIdHex: HexKey,
actor: HexKey,
ownerPubKey: HexKey,
citation: AuthorityCitation?,
heads: Map<String, EntityFloor>,
): Boolean {
if (actor.equals(ownerPubKey, ignoreCase = true)) return true
if (citation == null) return false
// Must name the actor's OWN Grant coordinate.
val eid = ConcordKeyDerivation.grantCoordinate(communityIdHex.hexToByteArray(), actor.lowercase().hexToByteArray()).toHexKey()
if (citation.grantId.toHexKey() != eid) return false
val head = heads[eid] ?: return false
return when {
head.version > citation.grantVersion -> true
// At exactly it: the hash must match our fold's winner, not a fork.
head.version == citation.grantVersion -> head.hashHex.equals(citation.grantHash.toHexKey(), ignoreCase = true)
// Behind it: park until the Grant arrives.
else -> false
}
}
}
@@ -31,9 +31,12 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
@@ -55,21 +58,14 @@ class ConcordInviteClassifyTest {
name = "Nostrichs", name = "Nostrichs",
) )
/** A raw kind-33301 event at the link-signer coordinate carrying an arbitrary [vsk] wire value. */ /** A kind-33301 event at the coordinate of [linkSigner], signed by it, carrying an arbitrary [vsk] wire value. */
private fun coordinateEvent( private fun coordinateEvent(
linkSignerPubKey: String, linkSigner: ByteArray,
vsk: String, vsk: String,
createdAt: Long, createdAt: Long,
content: String = "", content: String = "",
) = Event( dTag: String = "",
id = "00".repeat(32), ): Event = NostrSignerSync(KeyPair(privKey = linkSigner)).sign(createdAt, ConcordInviteBundleEvent.KIND, arrayOf(arrayOf("d", dTag), arrayOf(VskTag.TAG_NAME, vsk)), content)
pubKey = linkSignerPubKey,
createdAt = createdAt,
kind = ConcordInviteBundleEvent.KIND,
tags = arrayOf(arrayOf("d", ""), VskTag.TAG_NAME.let { arrayOf(it, vsk) }),
content = content,
sig = "00".repeat(64),
)
@Test @Test
fun liveBundleOpens() = fun liveBundleOpens() =
@@ -77,7 +73,7 @@ class ConcordInviteClassifyTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token) val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token)
assertTrue(status is InviteBundleStatus.Live) assertTrue(status is InviteBundleStatus.Live)
assertEquals(community.communityIdHex, status.invite.communityId) assertEquals(community.communityIdHex, status.invite.communityId)
} }
@@ -89,11 +85,11 @@ class ConcordInviteClassifyTest {
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
// A newer vsk=9 tombstone at the same coordinate buries the still-openable bundle. // A newer vsk=9 tombstone at the same coordinate buries the still-openable bundle.
val tombstone = coordinateEvent(minted.linkSignerPubKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L) val tombstone = coordinateEvent(minted.linkSignerPrivKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.token)) assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.linkSignerPubKey, minted.token))
// Order of the fetched list must not matter — newest createdAt wins regardless. // Order of the fetched list must not matter — newest createdAt wins regardless.
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.token)) assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.linkSignerPubKey, minted.token))
} }
@Test @Test
@@ -111,7 +107,7 @@ class ConcordInviteClassifyTest {
// Both fetch orders must resolve to the re-mint — `fetchAll` gives no ordering guarantee. // Both fetch orders must resolve to the re-mint — `fetchAll` gives no ordering guarantee.
listOf(listOf(minted.bundleEvent, remint), listOf(remint, minted.bundleEvent)).forEach { wraps -> listOf(listOf(minted.bundleEvent, remint), listOf(remint, minted.bundleEvent)).forEach { wraps ->
val status = ConcordInviteBundle.classify(wraps, minted.token) val status = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token)
assertTrue(status is InviteBundleStatus.Live) assertTrue(status is InviteBundleStatus.Live)
assertEquals("bb".repeat(32), status.invite.communityRoot) assertEquals("bb".repeat(32), status.invite.communityRoot)
assertEquals(2L, status.invite.rootEpoch) assertEquals(2L, status.invite.rootEpoch)
@@ -123,8 +119,9 @@ class ConcordInviteClassifyTest {
runTest { runTest {
// A mis-posted registry (vsk=8) at the bundle coordinate — the exact shape of the // A mis-posted registry (vsk=8) at the bundle coordinate — the exact shape of the
// relayop.xyz link that hung — is present but not a vsk=6 bundle we can open. // relayop.xyz link that hung — is present but not a vsk=6 bundle we can open.
val registry = coordinateEvent("aa".repeat(32), ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable") val signer = KeyPair()
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16))) val registry = coordinateEvent(signer.privKey!!, ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable")
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), signer.pubKey.toHexKey(), ByteArray(16)))
} }
/** /**
@@ -152,11 +149,11 @@ class ConcordInviteClassifyTest {
val wraps = listOf(minted.bundleEvent) val wraps = listOf(minted.bundleEvent)
// Before the expiry the very same bundle still opens… // Before the expiry the very same bundle still opens…
val live = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs - 1) val live = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token, nowMs = expiresAtMs - 1)
assertTrue(live is InviteBundleStatus.Live) assertTrue(live is InviteBundleStatus.Live)
// …and after it, the join path must refuse it (not Live) while the preview data survives. // …and after it, the join path must refuse it (not Live) while the preview data survives.
val expired = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs + 1) val expired = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token, nowMs = expiresAtMs + 1)
assertTrue(expired is InviteBundleStatus.Expired) assertTrue(expired is InviteBundleStatus.Expired)
assertEquals(community.communityIdHex, expired.invite.communityId) assertEquals(community.communityIdHex, expired.invite.communityId)
} }
@@ -167,12 +164,12 @@ class ConcordInviteClassifyTest {
runTest { runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live) assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live)
} }
@Test @Test
fun emptyFetchIsAbsent() { fun emptyFetchIsAbsent() {
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16))) assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), "aa".repeat(32), ByteArray(16)))
} }
@Test @Test
@@ -201,12 +198,12 @@ class ConcordInviteClassifyTest {
// End to end: what the creator publishes is what every redeemer then resolves. // End to end: what the creator publishes is what every redeemer then resolves.
val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L) val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L)
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.token)) assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.linkSignerPubKey, minted.token))
// And a re-mint that lands AFTER the grave un-revokes the link, which is exactly why the // And a re-mint that lands AFTER the grave un-revokes the link, which is exactly why the
// refresh path must skip a coordinate it did not resolve Live first. // refresh path must skip a coordinate it did not resolve Live first.
val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, inviteFor(community), createdAt = 3L) val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, inviteFor(community), createdAt = 3L)
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.token) is InviteBundleStatus.Live) assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.linkSignerPubKey, minted.token) is InviteBundleStatus.Live)
} }
@Test @Test
@@ -218,6 +215,86 @@ class ConcordInviteClassifyTest {
"""{"content":"ApoDjyzcHUg2imEiqw6Gsfpc2O86r+CMtMor+jc8ZlgrYwlI6CCmX7qGGEQvEJ5537nINE9H09Ro8RtEghpYgwkhdPHS274RpklFmuyLMdcoC5u1EVhppu8BrlHZ0YBfw3GX1Ui0uwy3V/J+rvrYiLhdREmwlK39JAX8sZfzCUhVtDMCgLVy03dwdpTC1Kj/ZeZJTYhJ8qmaN2273jgBTno/bFLzJlYvbANss69Tg53mljcmdSyhMlZ8z1kuenm1zkrPO5yHvi//r25tXkXb580OCkWxTmEwFzo20ntMgFnVSwVRvLZelOZt++tMevqi2Z5asvDgG7RytHP/0vLxxPzmjH0No+nITsxcmDbEweoKvSSzoc/7DYzENmfmrLXgP2KU/eE6CpTcSNaedLVKbAu9XptdtV8ruZxHjVBh1wpOwXkETEdqqvbCiR4TCNWzqbmwRKJ+acvZLBxhXcpfqmRsolaATU4sZKLs4iu92YpMIuUDh2Pquu0Daiz/IGnVe7BPb7E/gSd9NBFIxds6Nk1DbP8XKMRtYmWdTforUPWZqdM4EOtt8AcNpALRmsbEF26Gyd6t4/81bQPh+7WhI97lR/KkdWtKxNjjJ4CoJLgceyHuwbxXnFR23IWhzvQpBY12MBeYOw9oizvEzEGhEqpUns6LkH2sUNRRXbneNNvVgCEk6BK7j6Dxi95mcGJDEtOW+coE1SjhnfrwjIsdJL7cUEyC5DHFKuvxUi0iw/1I6b3AfZV5+A1tssEE2dhDv8uw6B3/a5EfMURFDqSfmGw1btdPPJ3+yjo1yYu2BtbYa4U++GtaAJfmNPrsB9lm4YgXuwCCRSpI2+TR9H2ntWM2j3HVdXqOpg3kfX82o9KFndo2g+7vGrOAyfL1jcybluq7AxPEV6D5yBky82MjoMeS0vSM6ytYu+0jheWPwDVs/3iPTELHPeDXAZOaw76ISBvNsXcxHvFsSiZBguBr+ucZOUnazVRAYIsmm/WNcIJu+6tfbyupqFCo5wkus6lKN2RNYIH1SRIi163cdBDhTBOdZoI2WcDr+SSW2fHtZutk7fW5IkJvSuy5xlke+YW/u3uzvriAIRmVDtk/fKISKEnMj2G47JdGn6EiHf+2+XfUSuDiliJb62pPXWBupinbb9HEW0tuyPHYGACH0/GA/egr6KMgI6YSh+BWS8vniMRTkmouKCzL5Csvc+2txC9LrfodrMF2R3jFZ1nig0mYzTQ9HvhqA2Uc+YG06iZtRaU7KqH6fMZYzPbjrxVOliyXR2G6","created_at":1784122846,"id":"112701bc1541c10b92f5a105e2e1f1813e591936e20075ec6a53c8bb8d235d81","kind":33301,"pubkey":"7177ccb8e8786c152e4960765f03fbceb7419d36a26e693a6399319760e7fd30","sig":"80eb4b49d70d73d35c1026b9c06d0fab280787950b5df412ebe4cdd05fcacadb4d20419c4e732749ed2698186a321069b4329ebd93fe21d8594d7392bf6445e0","tags":[["d",""],["vsk","8"]]}""" """{"content":"ApoDjyzcHUg2imEiqw6Gsfpc2O86r+CMtMor+jc8ZlgrYwlI6CCmX7qGGEQvEJ5537nINE9H09Ro8RtEghpYgwkhdPHS274RpklFmuyLMdcoC5u1EVhppu8BrlHZ0YBfw3GX1Ui0uwy3V/J+rvrYiLhdREmwlK39JAX8sZfzCUhVtDMCgLVy03dwdpTC1Kj/ZeZJTYhJ8qmaN2273jgBTno/bFLzJlYvbANss69Tg53mljcmdSyhMlZ8z1kuenm1zkrPO5yHvi//r25tXkXb580OCkWxTmEwFzo20ntMgFnVSwVRvLZelOZt++tMevqi2Z5asvDgG7RytHP/0vLxxPzmjH0No+nITsxcmDbEweoKvSSzoc/7DYzENmfmrLXgP2KU/eE6CpTcSNaedLVKbAu9XptdtV8ruZxHjVBh1wpOwXkETEdqqvbCiR4TCNWzqbmwRKJ+acvZLBxhXcpfqmRsolaATU4sZKLs4iu92YpMIuUDh2Pquu0Daiz/IGnVe7BPb7E/gSd9NBFIxds6Nk1DbP8XKMRtYmWdTforUPWZqdM4EOtt8AcNpALRmsbEF26Gyd6t4/81bQPh+7WhI97lR/KkdWtKxNjjJ4CoJLgceyHuwbxXnFR23IWhzvQpBY12MBeYOw9oizvEzEGhEqpUns6LkH2sUNRRXbneNNvVgCEk6BK7j6Dxi95mcGJDEtOW+coE1SjhnfrwjIsdJL7cUEyC5DHFKuvxUi0iw/1I6b3AfZV5+A1tssEE2dhDv8uw6B3/a5EfMURFDqSfmGw1btdPPJ3+yjo1yYu2BtbYa4U++GtaAJfmNPrsB9lm4YgXuwCCRSpI2+TR9H2ntWM2j3HVdXqOpg3kfX82o9KFndo2g+7vGrOAyfL1jcybluq7AxPEV6D5yBky82MjoMeS0vSM6ytYu+0jheWPwDVs/3iPTELHPeDXAZOaw76ISBvNsXcxHvFsSiZBguBr+ucZOUnazVRAYIsmm/WNcIJu+6tfbyupqFCo5wkus6lKN2RNYIH1SRIi163cdBDhTBOdZoI2WcDr+SSW2fHtZutk7fW5IkJvSuy5xlke+YW/u3uzvriAIRmVDtk/fKISKEnMj2G47JdGn6EiHf+2+XfUSuDiliJb62pPXWBupinbb9HEW0tuyPHYGACH0/GA/egr6KMgI6YSh+BWS8vniMRTkmouKCzL5Csvc+2txC9LrfodrMF2R3jFZ1nig0mYzTQ9HvhqA2Uc+YG06iZtRaU7KqH6fMZYzPbjrxVOliyXR2G6","created_at":1784122846,"id":"112701bc1541c10b92f5a105e2e1f1813e591936e20075ec6a53c8bb8d235d81","kind":33301,"pubkey":"7177ccb8e8786c152e4960765f03fbceb7419d36a26e693a6399319760e7fd30","sig":"80eb4b49d70d73d35c1026b9c06d0fab280787950b5df412ebe4cdd05fcacadb4d20419c4e732749ed2698186a321069b4329ebd93fe21d8594d7392bf6445e0","tags":[["d",""],["vsk","8"]]}"""
val event = Event.fromJson(json) val event = Event.fromJson(json)
val token = "c0277c415fe2ecc901a22b2f23dca5bf".hexToByteArray() val token = "c0277c415fe2ecc901a22b2f23dca5bf".hexToByteArray()
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), token)) assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), event.pubKey, token))
} }
// ---- S14: the relay filter is a hint, not a proof (CORD-05 §2) ----------------------
@Test
fun aForgedNewerRevocationByAnotherKeyDoesNotRevoke() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
// A relay serves a newer vsk=9 "at the coordinate" — but signed by someone else.
val forger = KeyPair()
val forged = coordinateEvent(forger.privKey!!, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, forged), minted.linkSignerPubKey, minted.token))
}
@Test
fun aNewerRevocationClaimingTheSignerButBadlySignedDoesNotRevoke() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
// Right pubkey, garbage signature: exactly what a relay could fabricate without the secret.
val forged =
Event(
id = "00".repeat(32),
pubKey = minted.linkSignerPubKey,
createdAt = 2L,
kind = ConcordInviteBundleEvent.KIND,
tags = arrayOf(arrayOf("d", ""), arrayOf(VskTag.TAG_NAME, ControlEntityKind.INVITE_REVOKED.wire)),
content = "",
sig = "00".repeat(64),
)
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, forged), minted.linkSignerPubKey, minted.token))
}
@Test
fun aRevocationAtAnotherDTagIsNotThisCoordinate() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
// Genuinely signed by the link signer, but at a different `d` — a different coordinate.
val elsewhere = coordinateEvent(minted.linkSignerPrivKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L, dTag = "x")
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, elsewhere), minted.linkSignerPubKey, minted.token))
}
@Test
fun aBundleFromTheWrongAuthorIsAbsent() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(listOf(minted.bundleEvent), "aa".repeat(32), minted.token))
}
// ---- S11: bundle bounds (CORD-05 §1) --------------------------------------------------
@Test
fun aBundleNamingTooManyChannelsIsRefused() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val huge = inviteFor(community).copy(channels = List(ConcordInviteBundle.MAX_BUNDLE_CHANNELS + 1) { InviteChannel(id = it.toString(), epoch = 0) })
val minted = ConcordInviteBundle.mintLink("https://vector.chat", huge, createdAt = 1L)
assertNull(ConcordInviteBundle.bound(huge))
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token))
val atCap = inviteFor(community).copy(channels = List(ConcordInviteBundle.MAX_BUNDLE_CHANNELS) { InviteChannel(id = it.toString(), epoch = 0) })
val ok = ConcordInviteBundle.mintLink("https://vector.chat", atCap, createdAt = 1L)
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(ok.bundleEvent), ok.linkSignerPubKey, ok.token))
}
@Test
fun aBundlesRelaysAreTruncatedToTheCommunityCap() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val relays = List(40) { "wss://r$it.example" }
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community).copy(relays = relays + relays), createdAt = 1L)
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token)
assertIs<InviteBundleStatus.Live>(status)
assertEquals(relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), status.invite.relays)
}
} }
@@ -32,6 +32,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
class ConcordInviteLinkTest { class ConcordInviteLinkTest {
@@ -67,9 +68,10 @@ class ConcordInviteLinkTest {
} }
@Test @Test
fun buildUrlCarriesEveryRelayOfAnOversizedList() { fun buildUrlTruncatesAnOversizedListToTheBootstrapCap() {
// A community with five relays used to crash the mint ("at most 3 relays, was 5"). // A community with five relays used to crash the mint ("at most 3 relays, was 5"), and was
// There is no cap below the format's own, so all five make the round trip. // then fixed by carrying all five — which the reference client's decoder refuses. The
// fragment only has to find the bundle (CORD-05 §3), so the first three make the trip.
val relays = val relays =
listOf( listOf(
"wss://one.example", "wss://one.example",
@@ -80,7 +82,7 @@ class ConcordInviteLinkTest {
) )
val parsed = ConcordInviteLink.parseUrl(ConcordInviteLink.buildUrl("https://vector.chat", signer, token, relays)) val parsed = ConcordInviteLink.parseUrl(ConcordInviteLink.buildUrl("https://vector.chat", signer, token, relays))
assertNotNull(parsed) assertNotNull(parsed)
assertEquals(relays, parsed.fragment.relays) assertEquals(relays.take(3), parsed.fragment.relays)
assertContentEquals(token, parsed.fragment.token) assertContentEquals(token, parsed.fragment.token)
} }
@@ -94,18 +96,6 @@ class ConcordInviteLinkTest {
assertEquals(InviteRelayDictionary.STOCK, parsed.fragment.relays) assertEquals(InviteRelayDictionary.STOCK, parsed.fragment.relays)
} }
@Test
fun encodesTheLargestRelayListTheCountByteCanHold() {
// 255 is the format ceiling, not a policy one: the relay count is a single byte.
val relays = List(255) { "wss://relay$it.example" }
val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays))
assertEquals(relays, frag.relays)
assertContentEquals(token, frag.token)
// One more would silently wrap the count byte to 0 and strand every relay, so it throws.
assertFailsWith<IllegalArgumentException> { ConcordInviteLink.encodeFragment(token, relays + "wss://overflow.example") }
}
@Test @Test
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
fun rejectsWrongVersion() { fun rejectsWrongVersion() {
@@ -134,4 +124,32 @@ class ConcordInviteLinkTest {
assertContentEquals(k, ConcordKeyDerivation.inviteBundleKey(token)) assertContentEquals(k, ConcordKeyDerivation.inviteBundleKey(token))
assertFalse(k.toHexKey() == ConcordKeyDerivation.inviteBundleKey(ByteArray(16) { 0x09 }).toHexKey()) assertFalse(k.toHexKey() == ConcordKeyDerivation.inviteBundleKey(ByteArray(16) { 0x09 }).toHexKey())
} }
// ---- I11: at most 3 bootstrap relays (CORD-05 §3) -------------------------------------
@Test
fun encodingTruncatesToThreeBootstrapRelays() {
val token = ByteArray(16) { 7 }
val relays = listOf("wss://a.example", "wss://b.example", "wss://c.example", "wss://d.example", "wss://e.example")
val decoded = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays))
assertEquals(relays.take(ConcordInviteLink.MAX_BOOTSTRAP_RELAYS), decoded.relays)
}
@Test
fun theStockSetIsExemptFromTheCap() {
val decoded = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(ByteArray(16), InviteRelayDictionary.STOCK))
assertEquals(InviteRelayDictionary.STOCK, decoded.relays)
assertTrue(decoded.usedStockRelays)
}
@OptIn(ExperimentalEncodingApi::class)
@Test
fun decodingRefusesMoreThanThreeBootstrapRelays() {
// version 4, flags 0, count 4, four dictionary ids, then the token — what a non-conforming
// encoder would emit and the reference client's decoder throws on.
val bytes = byteArrayOf(4, 0, 4, 1, 2, 3, 4) + ByteArray(16)
val fragment = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes)
assertFailsWith<IllegalArgumentException> { ConcordInviteLink.decodeFragment(fragment) }
assertNull(ConcordInviteLink.parseUrl("https://x/invite/" + ConcordInviteLink.buildUrl("https://x", "aa".repeat(32), ByteArray(16)).substringAfter("/invite/").substringBefore('#') + "#" + fragment))
}
} }
@@ -194,4 +194,40 @@ class ConcordInviteListTest {
assertTrue(!live.isExpired(nowSecs = 99)) assertTrue(!live.isExpired(nowSecs = 99))
assertTrue(!forever.isExpired(nowSecs = Long.MAX_VALUE), "no expiry means it never elapses") assertTrue(!forever.isExpired(nowSecs = Long.MAX_VALUE), "no expiry means it never elapses")
} }
// ---- I18: entries are immutable; malformed tombstones ride as residue (CORD-05 §4) ----
@Test
fun anExistingTokensEntryIsImmutableSoTheFirstCopyWins() {
// The published list (base) already holds t1; a device's patch carrying a different copy of
// the same token must not rewrite its signer_sk or url (the reference client's first-wins).
val base = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t1", "sk-original", "c", "url-original", createdAt = 1)))
val patch = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t1", "sk-other", "c", "url-other", label = "late", createdAt = 9)))
val merged = ConcordInviteList.merge(base, patch)
assertEquals(1, merged.entries.size)
assertEquals("sk-original", merged.entries.first().signerSk)
assertEquals("url-original", merged.entries.first().url)
assertEquals(null, merged.entries.first().label)
}
@Test
fun aTombstoneThatFailsToTypeCheckIsCarriedNotDropped() {
val json =
"""
{ "entries": [ { "token": "aa", "signer_sk": "bb", "community_id": "cc", "url": "u" } ],
"tombstones": [ { "token": "aa", "community_id": {"weird": true}, "mark": "grave" } ] }
""".trimIndent()
val doc = ConcordInviteList.decodeOrNull(json)!!
assertEquals(0, doc.tombstones.size)
assertEquals(1, doc.opaqueTombstones.size, "the untyped tombstone is kept")
assertTrue(ConcordInviteList.encode(doc).contains("grave"), "an untyped tombstone was lost on re-encode")
// It still retires the token it names: a merge must not let the entry stay live.
val merged = ConcordInviteList.merge(doc, ConcordInviteListDocument.EMPTY)
assertTrue(merged.entries.none { it.token == "aa" }, "an untyped tombstone must still beat its entry")
assertTrue(ConcordInviteList.encode(merged).contains("grave"), "merge dropped an untyped tombstone")
}
} }
@@ -61,7 +61,7 @@ class ConcordInviteRelayopInteropTest {
assertEquals("https://blossom.primal.net/a85a6b8f68cf602591b16846e1605f8034587b7220b44d7076d09f5e3bf5af71.jpg", invite.icon?.url) assertEquals("https://blossom.primal.net/a85a6b8f68cf602591b16846e1605f8034587b7220b44d7076d09f5e3bf5af71.jpg", invite.icon?.url)
assertEquals(false, invite.icon?.isResolvable()) assertEquals(false, invite.icon?.isResolvable())
val status = ConcordInviteBundle.classify(listOf(event), token) val status = ConcordInviteBundle.classify(listOf(event), event.pubKey, token)
assertIs<InviteBundleStatus.Live>(status) assertIs<InviteBundleStatus.Live>(status)
assertEquals("3rd times a charm?", status.invite.name) assertEquals("3rd times a charm?", status.invite.name)
} }
@@ -75,14 +75,14 @@ class ConcordStrandedRecoveryTest {
name = "Gamers", name = "Gamers",
) )
// ---- merge forward -------------------------------------------------------- // ---- explicit re-join (the user accepts the link again) ---------------------
@Test @Test
fun higherEpochBundleMergesForwardKeepingAnchorAndHistory() { fun anExplicitRejoinOfAHigherEpochBundleKeepsAnchorAndHistory() {
val prior = HeldRoot(0L, "aa".repeat(32)) val prior = HeldRoot(0L, "aa".repeat(32))
val stranded = entry(epoch = 1, heldRoots = listOf(prior)) val stranded = entry(epoch = 1, heldRoots = listOf(prior))
val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false) val merged = ConcordStrandedRecovery.rejoinForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false)
assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind") assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind")
// adopted the new epoch's access root // adopted the new epoch's access root
@@ -98,22 +98,21 @@ class ConcordStrandedRecoveryTest {
assertTrue(merged.heldRoots.any { it.epoch == 0L && it.key == prior.key }) assertTrue(merged.heldRoots.any { it.epoch == 0L && it.key == prior.key })
assertTrue(merged.heldRoots.any { it.epoch == 1L && it.key == "bb".repeat(32) }) assertTrue(merged.heldRoots.any { it.epoch == 1L && it.key == "bb".repeat(32) })
// identity is untouched and we record where we were dropped // identity is untouched
assertEquals(communityId, merged.id) assertEquals(communityId, merged.id)
assertEquals(stranded.addedAt, merged.addedAt) assertEquals(stranded.addedAt, merged.addedAt)
assertEquals(1L, merged.excludedAtEpoch)
} }
@Test @Test
fun sameEpochBundleIsANoOp() { fun sameEpochBundleIsANoOp() {
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false)) assertNull(ConcordStrandedRecovery.rejoinForward(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false))
assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false)) assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false))
} }
@Test @Test
fun lowerEpochBundleIsANoOp() { fun lowerEpochBundleIsANoOp() {
// Epoch-monotonic: a stale bundle must never walk the membership backwards. // Epoch-monotonic: a stale bundle must never walk the membership backwards.
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3), bannedAtCurrentEpoch = false)) assertNull(ConcordStrandedRecovery.rejoinForward(entry(epoch = 7), bundle(epoch = 3), bannedAtCurrentEpoch = false))
} }
@Test @Test
@@ -121,12 +120,12 @@ class ConcordStrandedRecoveryTest {
// Direct invites and legacy entries have no anchor — expected, not an error. // Direct invites and legacy entries have no anchor — expected, not an error.
val noAnchor = entry(epoch = 1, ref = null) val noAnchor = entry(epoch = 1, ref = null)
assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false)) assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false))
assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false)) assertNull(ConcordStrandedRecovery.rejoinForward(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false))
} }
@Test @Test
fun bundleForAnotherCommunityIsIgnored() { fun bundleForAnotherCommunityIsIgnored() {
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)), bannedAtCurrentEpoch = false)) assertNull(ConcordStrandedRecovery.rejoinForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)), bannedAtCurrentEpoch = false))
} }
// ---- the bare `<naddr>#<fragment>` anchor form ---------------------------- // ---- the bare `<naddr>#<fragment>` anchor form ----------------------------
@@ -260,8 +259,24 @@ class ConcordStrandedRecoveryTest {
// very epoch a Refounding rotated them out of. See A2 in docs/concord-soft-ban-audit.md. // very epoch a Refounding rotated them out of. See A2 in docs/concord-soft-ban-audit.md.
val stranded = entry(epoch = 1) val stranded = entry(epoch = 1)
assertFalse(ConcordStrandedRecovery.isStranded(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true)) assertFalse(ConcordStrandedRecovery.isStranded(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true))
assertNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true)) assertNull(ConcordStrandedRecovery.rejoinForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true))
// ...and the legitimate case still works, so the gate is not just "recovery off". // ...and the legitimate case still works, so the gate is not just "recovery off".
assertNotNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false)) assertNotNull(ConcordStrandedRecovery.rejoinForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false))
}
/**
* S4: detection is all a bundle may do on its own. The class exposes no function that moves a
* held community's base from a bundle without the user re-accepting the link — a link creator
* could otherwise relocate every member who joined through their link onto a root they chose.
*/
@Test
fun aHostileHigherEpochBundleIsOnlyDetectedNeverAdoptedBySweep() {
val held = entry(epoch = 1)
val hostile = bundle(epoch = 2, root = "ee".repeat(32))
// The sweep's question: are we stranded? Yes — and that is all it learns.
assertTrue(ConcordStrandedRecovery.isStranded(held, hostile, bannedAtCurrentEpoch = false))
// The held entry itself is untouched by detection.
assertEquals("bb".repeat(32), held.root)
assertEquals(1L, held.rootEpoch)
} }
} }
@@ -0,0 +1,368 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord06Rekey
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* CORD-06 wire conformance against the spec and the reference client: 1-based chunks (I8),
* byte-budgeted chunks under the NIP-44 cap (I10), the `vac` citation on rotations (I9),
* race convergence + idempotent retry (I12), fold-all-or-abort compaction (S12), and the
* encrypted rekey seal.
*/
class ConcordRekeyConformanceTest {
private val owner = NostrSignerInternal(KeyPair())
private val admin = NostrSignerInternal(KeyPair())
private val member = NostrSignerInternal(KeyPair())
private val now = 1_700_000_000L
private val controlRoot = ByteArray(32) { 0x6B }
private suspend fun rotation(
community: NewConcordCommunity,
rotator: NostrSigner,
newRoot: ByteArray,
recipients: List<String>,
staff: Set<String> = emptySet(),
authority: AuthorityCitation? = null,
): List<Event> {
val newEpoch = community.rootEpoch + 1
return ConcordRefounding.buildBaseRekeyWraps(
rotatorSigner = rotator,
baseRekeyKey = baseRekey(community),
recipientsXOnly = recipients,
staffXOnly = staff,
newRoot = newRoot,
newControlPk = ConcordKeyDerivation.controlSignerKey(controlRoot, community.communityId, newEpoch).publicKey,
newControlRoot = controlRoot,
newEpoch = newEpoch,
prevEpoch = community.rootEpoch,
prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey(),
createdAt = now,
authority = authority,
)
}
private fun baseRekey(community: NewConcordCommunity): GroupKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, community.rootEpoch + 1)
private fun rumorsOf(
wraps: List<Event>,
key: GroupKey,
) = wraps.map { assertNotNull(ConcordStreamEnvelope.openOrNull(it, key)) }
private fun members(n: Int) = List(n) { KeyPair().pubKey.toHexKey() }
// ---- I8: chunk indices are 1-based --------------------------------------------------
@Test
fun chunksAreNumberedFromOne() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Test", now)
val wraps = rotation(community, owner, ByteArray(32) { 1 }, members(250) + member.pubKey)
val opened = rumorsOf(wraps, baseRekey(community))
val chunks = opened.map { o -> o.rumor.tags.first { it[0] == ConcordRekey.TAG_CHUNK } }
assertEquals((1..wraps.size).map { it.toString() }, chunks.map { it[1] })
assertTrue(chunks.all { it[2] == wraps.size.toString() })
assertTrue(opened.all { ConcordRekey.chunkOf(it.rumor.tags) != null })
}
@Test
fun chunkTagParsingIsStrict() {
fun chunk(vararg v: String) = arrayOf(arrayOf(ConcordRekey.TAG_CHUNK, *v))
assertEquals(1 to 1, ConcordRekey.chunkOf(emptyArray()), "absent reads as the only chunk")
assertEquals(2 to 3, ConcordRekey.chunkOf(chunk("2", "3")))
assertNull(ConcordRekey.chunkOf(chunk("0", "2")), "0-based is malformed")
assertNull(ConcordRekey.chunkOf(chunk("3", "2")))
assertNull(ConcordRekey.chunkOf(chunk("1", "0")))
assertNull(ConcordRekey.chunkOf(chunk("01", "2")))
assertNull(ConcordRekey.chunkOf(chunk("+1", "2")))
assertNull(ConcordRekey.chunkOf(chunk("1")))
assertFailsWith<IllegalArgumentException> { ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, 1, 0, "ab".repeat(32), 0, 1) }
}
@Test
fun aZeroBasedChunkIsDropped() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Test", now)
val newEpoch = community.rootEpoch + 1
val blob = ConcordRekey.blobForSigner(owner, member.pubKey.hexToByteArray(), ConcordRekey.ROOT_SCOPE, newEpoch, ByteArray(32) { 1 })
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey()
val tags =
arrayOf(
arrayOf(ConcordRekey.TAG_SCOPE, ConcordRekey.ROOT_SCOPE.toHexKey()),
arrayOf(ConcordRekey.TAG_NEWEPOCH, newEpoch.toString()),
arrayOf(ConcordRekey.TAG_PREVEPOCH, community.rootEpoch.toString()),
arrayOf(ConcordRekey.TAG_PREVCOMMIT, prevCommit),
arrayOf(ConcordRekey.TAG_CHUNK, "0", "1"),
)
val rumor = RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey(community), owner, encrypted = true, createdAt = now)
assertNull(ConcordRefounding.findNewRoot(listOf(wrap), baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch))
}
// ---- rekey seals must be encrypted (CORD-02 §5) ------------------------------------
@Test
fun aPlaintextSealedRekeyIsIgnored() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Test", now)
val encrypted = rotation(community, owner, ByteArray(32) { 1 }, listOf(member.pubKey))
val rumor = rumorsOf(encrypted, baseRekey(community)).single().rumor
val plaintext = ConcordStreamEnvelope.wrap(rumor, baseRekey(community), owner, encrypted = false, createdAt = now)
assertNotNull(ConcordRefounding.findNewRoot(encrypted, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch))
assertNull(ConcordRefounding.findNewRoot(listOf(plaintext), baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch))
}
// ---- I10: chunk by bytes so the wrap stays under NIP-44's 65,535-byte plaintext ---------
@Test
fun staffChunksStayUnderTheNip44Cap() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Test", now)
val recipients = members(300) + member.pubKey
// Every recipient staff: the widest (136-byte) blob. 120 of these overflowed the cap.
val wraps = rotation(community, owner, ByteArray(32) { 1 }, recipients, staff = recipients.toSet())
val opened = rumorsOf(wraps, baseRekey(community))
for (o in opened) {
assertTrue(
o.rumor
.toJson()
.encodeToByteArray()
.size <= ConcordRekey.REKEY_RUMOR_MAX_BYTES,
"rumor over the byte budget",
)
assertTrue(
o.seal
.toJson()
.encodeToByteArray()
.size <= 65_535,
"the wrap's NIP-44 plaintext (the seal) must fit the cap",
)
assertTrue(ConcordRekey.decodeContent(o.rumor.content).size <= 90, "the reference client fits 90 staff blobs per chunk")
}
assertEquals(recipients.size, opened.sumOf { ConcordRekey.decodeContent(it.rumor.content).size })
// And everyone still finds their key across the chunks.
assertNotNull(ConcordRefounding.findNewRoot(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch))
}
@Test
fun memberChunksStayUnderTheNip44Cap() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Test", now)
val wraps = rotation(community, owner, ByteArray(32) { 1 }, members(250))
for (o in rumorsOf(wraps, baseRekey(community))) {
assertTrue(
o.seal
.toJson()
.encodeToByteArray()
.size <= 65_535,
)
// ~99 per chunk (the reference client's figure; our slimmer rumor envelope fits a few more).
assertTrue(
o.rumor
.toJson()
.encodeToByteArray()
.size <= ConcordRekey.REKEY_RUMOR_MAX_BYTES,
)
assertTrue(ConcordRekey.decodeContent(o.rumor.content).size < ConcordRekey.MAX_BLOBS_PER_CHUNK, "the byte budget, not the count cap, binds for 104-byte blobs")
}
}
@Test
fun chunkingKeepsTheCountCap() {
val tiny = List(300) { RekeyBlob("a", "b") }
val chunks = ConcordRekey.chunkBlobs(tiny, envelopeBytes = 300)
assertEquals(listOf(120, 120, 60), chunks.map { it.size })
assertEquals(listOf(0), ConcordRekey.chunkBlobs(emptyList(), 300).map { it.size })
}
// ---- I9: the vac citation --------------------------------------------------------------
@Test
fun everyChunkCarriesTheRotatorsCitation() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Test", now)
val citation = AuthorityCitation(ByteArray(32) { 3 }, 4, ByteArray(32) { 5 })
val wraps = rotation(community, admin, ByteArray(32) { 1 }, members(250) + member.pubKey, authority = citation)
for (o in rumorsOf(wraps, baseRekey(community))) {
assertEquals(
VacTag.assemble(citation).toList(),
o.rumor.tags
.first { it[0] == VacTag.TAG_NAME }
.toList(),
)
}
val got = ConcordRefounding.findNewRoot(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch)
assertNotNull(got)
assertEquals(admin.pubKey, got.rotator)
assertContentEquals(citation.grantId, got.authority?.grantId)
assertEquals(4L, got.authority?.grantVersion)
// The owner cites nothing.
val byOwner = rotation(community, owner, ByteArray(32) { 1 }, listOf(member.pubKey))
assertTrue(rumorsOf(byOwner, baseRekey(community)).all { o -> o.rumor.tags.none { it[0] == VacTag.TAG_NAME } })
}
@Test
fun citationsAreVerifiedAgainstTheFoldedGrantHead() {
val cid = "11".repeat(32)
val ownerHex = owner.pubKey
val grantEid = ConcordKeyDerivation.grantCoordinate(cid.hexToByteArray(), admin.pubKey.hexToByteArray()).toHexKey()
val hash = "ab".repeat(32)
val heads = mapOf(grantEid to EntityFloor(3, hash))
val minted = ConcordRotationAuthority.citationFor(cid, admin.pubKey, ownerHex, heads)
assertNotNull(minted)
assertEquals(grantEid, minted.grantId.toHexKey())
assertEquals(3L, minted.grantVersion)
assertNull(ConcordRotationAuthority.citationFor(cid, ownerHex, ownerHex, heads), "the owner cites nothing")
fun ok(c: AuthorityCitation?) = ConcordRotationAuthority.citationSatisfied(cid, admin.pubKey, ownerHex, c, heads)
assertTrue(ok(minted))
assertTrue(ConcordRotationAuthority.citationSatisfied(cid, ownerHex, ownerHex, null, heads), "the owner needs no citation")
assertFalse(ok(null), "a delegated rotator must cite")
assertTrue(ok(AuthorityCitation(minted.grantId, 2, ByteArray(32))), "our head is newer than the cited Grant: rank decides")
assertFalse(ok(AuthorityCitation(minted.grantId, 4, ByteArray(32))), "cites a Grant we have not synced: park")
assertFalse(ok(AuthorityCitation(minted.grantId, 3, ByteArray(32) { 9 })), "same version, different hash: a fork")
val otherEid = ConcordKeyDerivation.grantCoordinate(cid.hexToByteArray(), member.pubKey.hexToByteArray())
assertFalse(ok(AuthorityCitation(otherEid, 3, hash.hexToByteArray())), "must cite the rotator's OWN Grant")
}
// ---- I12: race convergence + idempotent retry --------------------------------------------
@Test
fun racingRotationsConvergeOnTheLowestAuthorizedRoot() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Test", now)
val high = ByteArray(32) { 0x5A }
val low = ByteArray(32) { 0x10 }
val wraps = rotation(community, owner, high, listOf(member.pubKey)) + rotation(community, admin, low, listOf(member.pubKey))
val all = ConcordRefounding.findNewRoots(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch)
assertEquals(2, all.size)
// Fetch order must not matter: every client picks the same winner.
for (order in listOf(wraps, wraps.reversed())) {
val won = ConcordRefounding.findNewRoot(order, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch)
assertNotNull(won)
assertContentEquals(low, won.newRoot)
}
// Authorize before converging: an unauthorized lower root never wins.
val onlyOwner = ConcordRefounding.findNewRoot(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch) { it.rotator == owner.pubKey }
assertNotNull(onlyOwner)
assertContentEquals(high, onlyOwner.newRoot)
}
@Test
fun theSameEpochHealIsDownOnly() {
val low = ByteArray(32) { 0x10 }
val high = ByteArray(32) { 0x5A }
assertTrue(ConcordRefounding.healsTo(held = high, candidate = low))
assertFalse(ConcordRefounding.healsTo(held = low, candidate = high), "a flaky fetch of the higher sibling must not re-fork")
assertFalse(ConcordRefounding.healsTo(held = low, candidate = low))
// Unsigned order: 0x80 sorts above 0x7F.
assertTrue(ConcordRefounding.compareKeys(byteArrayOf(0x7F), byteArrayOf(0x80.toByte())) < 0)
}
@Test
fun losingForkRootsAreKeptButNotFoldedFrom() {
val held =
listOf(
HeldRoot(1, "5a".repeat(32), controlPk = null),
HeldRoot(1, "10".repeat(32), controlPk = "cc".repeat(32)),
HeldRoot(0, "aa".repeat(32)),
)
val canonical = ConcordRefounding.canonicalHeldRoots(held)
assertEquals(setOf(0L to "aa".repeat(32), 1L to "10".repeat(32)), canonical.map { it.epoch to it.key }.toSet())
}
@Test
fun aRetriedRefoundingReusesItsReservedKeys() {
val cid = "11".repeat(32)
val priorRoot = ByteArray(32) { 2 }
val first = ConcordRefounding.reserveKeys(null, cid, 3, priorRoot)
val retry = ConcordRefounding.reserveKeys(first, cid, 3, priorRoot)
assertSame(first, retry, "a retry must re-deliver the same root, never mint a sibling")
// A different rotation (another epoch, or another prior root) gets fresh keys.
val nextEpoch = ConcordRefounding.reserveKeys(first, cid, 4, priorRoot)
assertFalse(nextEpoch.newRoot.contentEquals(first.newRoot))
val otherRoot = ConcordRefounding.reserveKeys(first, cid, 3, ByteArray(32) { 9 })
assertFalse(otherRoot.newRoot.contentEquals(first.newRoot))
}
// ---- S12: fold-all-or-abort compaction ---------------------------------------------------
@Test
fun compactionAbortsWhenAnHonoredHeadIsMissing() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Test", now, description = "A place")
val newControl = ControlPlaneKeys.forStaff(ByteArray(32) { 7 }, community.communityId, community.rootEpoch + 1, controlRoot)
val heads =
community.genesisWraps
.mapNotNull { ConcordStreamEnvelope.openOrNull(it, community.controlPlane)?.let { o -> ControlEdition.fromRumor(o.rumor) } }
.associate { it.entityIdHex to it.version }
// Everything we honor is present: the compaction goes ahead.
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, owner.pubKey, mustCarry = heads)
assertEquals(heads.size, compacted.size)
// An entity we fold (or a newer version of one) that the fetched plane lacks: abort.
val missing =
assertFailsWith<IncompleteControlPlaneException> {
ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, owner.pubKey, mustCarry = heads + ("ff".repeat(32) to 0L))
}
assertEquals(listOf("ff".repeat(32)), missing.missing)
val first = heads.keys.first()
assertFailsWith<IncompleteControlPlaneException> {
ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, owner.pubKey, mustCarry = mapOf(first to heads.getValue(first) + 1))
}
}
}
@@ -194,7 +194,7 @@ class ControlRootRotationTest {
val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch) val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch)
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey() val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey()
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 0, 1) val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 1, 1)
val rumor = val rumor =
RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob))) RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now) val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now)
@@ -224,7 +224,7 @@ class ControlRootRotationTest {
val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch) val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch)
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey() val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey()
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 0, 1) val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 1, 1)
val rumor = val rumor =
RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob))) RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now) val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now)