fix(concord): close ten review findings in the CORD-05 invite path

A high-effort review of the invite work found ten correctness bugs, nine
confirmed and one plausible. All are fixed here; the on-device pass on a
tablet proved the three that are observable through the UI.

The load-bearing one: the kind-13303 Invite List is replaceable, and both
clients merged a patch onto a base that silently degraded to EMPTY whenever
the read failed — an unanswered relay or a bunker signer declining one
decrypt was enough. Republishing that destroys every `signer_sk` it could
not read, and those secrets cannot be regenerated, so every outstanding
link is orphaned at a dead epoch. `decode` is now `decodeOrNull` and
`decrypt` returns null, so "I could not read it" is distinguishable from
"it is empty", and the write aborts rather than overwriting.

The rest:

- `join` is now ban-gated on both clients. A Refounding re-mints every
  outstanding link onto the new root, and an ex-member keeps the URL and
  its token forever, so the rotation meant to expel them handed them the
  new keys instead. Fails closed on an unreadable plane.
- Android's Refounding re-read the entry from `liveCommunities` straight
  after adopting the new root, but that flow decrypts asynchronously, so
  every link was re-minted onto the epoch just left. `adoptConcordRoot`
  now returns the entry it wrote.
- amy's refound folded the fresh bans locally and then never used them,
  re-draining from relays instead; a relay slow to echo them back would
  produce a new epoch whose roster never banned anyone.
- Link refresh rebuilt the bundle from scratch, stripping expiry, channel
  grants, icon and label; it now moves the link's own current bundle and
  changes only the epoch's key material.
- Refresh also re-posted over revocation tombstones, silently un-revoking
  a retired link.
- The 13303 coordinate is (13303, me, "") — one list per account — but was
  read and written on per-community relays, forking it into divergent
  versions that newest-wins then collapsed. Now account-outbox only.
- Minting returned the URL even when recording the link failed, handing
  out a link that could never be refreshed. It now fails closed.
- amy's refound had no equivalent of Amethyst's recipient cap, leaving the
  attacker-writable half of the union unbounded.
- amy's store dropped the banked epoch's `controlRoot` on the round-trip,
  losing the staff write key that rebuilds the anti-rollback floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-09 23:39:54 -04:00
co-authored by Claude Opus 5
parent 66d2777262
commit eb8c812690
11 changed files with 339 additions and 141 deletions
@@ -171,74 +171,97 @@ class AccountConcordActions(
// ---- CORD-05 Invite List (kind 13303) -------------------------------------
/**
* This account's Invite List: the creator's private, self-encrypted record of every link they
* minted (`token` + `signer_sk` per entry). Empty when none was ever published.
* This account's Invite List (kind 13303): the creator's private, self-encrypted record of every
* link they minted (`token` + `signer_sk` per entry).
*
* Fetched rather than read from [LocalCache] because nothing subscribes to 13303 — it is
* Returns **null** when the list could not be read — no relay answered, or the signer refused
* the decrypt — and an empty document only when the account genuinely has no list yet. Callers
* must not conflate the two: republishing an "empty" list over this replaceable coordinate
* destroys every `signer_sk` it failed to read, and those secrets cannot be regenerated.
*
* Read on the account's OUTBOX relays, never a community's: the coordinate is
* (13303, me, "") — one list for the whole account — so scoping it per community would fork it
* into divergent versions that the newest-wins rule then silently collapses.
*
* Fetched rather than read from [LocalCache] because nothing subscribes to 13303: it is
* bookkeeping the user never sees, needed only at mint and at rotation.
*/
private suspend fun readConcordInviteList(relays: Set<NormalizedRelayUrl>): ConcordInviteListDocument {
if (relays.isEmpty()) return ConcordInviteListDocument.EMPTY
private suspend fun readConcordInviteList(): ConcordInviteListDocument? {
val relays = account.outboxRelays.flow.value
if (relays.isEmpty()) return null
val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(account.signer.pubKey))
val newest =
account.client
.fetchAll(filters = relays.associateWith { listOf(filter) })
.maxByOrNull { it.createdAt }
return (newest as? ConcordInviteListEvent)?.decrypt(account.signer) ?: ConcordInviteListDocument.EMPTY
?: return ConcordInviteListDocument.EMPTY // nothing published yet — safe to start one
return (newest as? ConcordInviteListEvent)?.decrypt(account.signer)
}
/**
* Merges [patch] into the published Invite List and republishes it. Read-merge-write, never
* overwrite: the list is replaceable and per-creator, so two of the user's devices minting
* concurrently would otherwise delete each other's `signer_sk` — and that secret is
* unrecoverable, orphaning the link at whatever epoch it was last refreshed to.
* Merges [patch] into the published Invite List and republishes it, returning whether it landed.
*
* Read-merge-write, and **aborts rather than overwriting** when the read fails: the list is
* replaceable, so publishing a patch-only document over an unread list deletes every other
* link's `signer_sk` — unrecoverable, and it strands every holder of those links at the next
* rotation. A momentarily unreachable relay or a bunker signer that declines one decrypt is
* enough to trigger that, which is exactly how the kind-13302 community list was once emptied.
*/
private suspend fun publishConcordInviteList(
patch: ConcordInviteListDocument,
relays: Set<NormalizedRelayUrl>,
) {
val publishTo = relays.ifEmpty { account.outboxRelays.flow.value }
if (publishTo.isEmpty()) return
val merged = ConcordInviteList.merge(readConcordInviteList(publishTo), patch)
account.client.publish(ConcordInviteListEvent.create(account.signer, merged, TimeUtils.now()), publishTo)
private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): Boolean {
val publishTo = account.outboxRelays.flow.value
if (publishTo.isEmpty()) return false
val base =
readConcordInviteList() ?: run {
Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" }
return false
}
return runCatching {
account.client.publish(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo)
true
}.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false)
}
/**
* Re-posts every live link this account minted for [entry]'s community at its own coordinate,
* carrying the CURRENT epoch (CORD-05). The kind-33301 bundle is addressable and authored by the
* carrying [entry]'s epoch (CORD-05). The kind-33301 bundle is addressable and authored by the
* link signer, so this moves the link behind the same URL instead of orphaning it at a dead
* epoch — which is the whole premise stranded recovery rests on.
*
* Safe to call for every live link: recovery is ban-gated at the epoch being left, and a
* Refounding bans the members it removes on the way out, so a removed member's own recovery is
* refused even though their link now resolves.
* [entry] MUST be the post-rotation entry, passed in rather than re-read: the joined-list flow
* decrypts asynchronously, so reading it straight after adopting a new root yields the OLD
* epoch and would re-mint every link onto the epoch we just left.
*
* Each link is refreshed from its own CURRENT bundle, not rebuilt from scratch, so per-link
* fields the bundle carries — expiry, channel grants, icon, label — survive the rotation. A
* coordinate whose newest event is a revocation tombstone is left alone: re-posting a live
* bundle over it would silently un-revoke the link.
*/
private suspend fun refreshConcordInviteLinks(entry: ConcordCommunityListEntry): Int {
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
if (relays.isEmpty()) return 0
val list = readConcordInviteList() ?: return 0
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
val now = TimeUtils.now()
val refreshed =
ConcordActions.inviteFor(
communityIdHex = entry.id,
ownerPubKey = entry.owner,
ownerSaltHex = entry.ownerSalt,
communityRootHex = entry.root,
rootEpoch = entry.rootEpoch,
name = entry.name,
relays = entry.relays,
controlPk = entry.controlPk,
)
var count = 0
for (link in readConcordInviteList(relays).entries) {
for (link in list.entries) {
if (link.communityId != entry.id) continue
// An elapsed link can no longer be joined, so re-posting it would only resurrect a dead
// URL at a live epoch.
if (link.isExpired(now)) continue
// An elapsed or retired link can no longer be joined; re-posting it would only resurrect
// a dead URL at a live epoch.
if (link.isExpired(now) || link.token in tombstoned) continue
runCatching {
account.client.publish(
ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), link.token.hexToByteArray(), refreshed, now),
relays,
)
val token = link.token.hexToByteArray()
val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) })
// Honour a revocation published at this coordinate, and carry the live bundle's own
// fields forward — only the epoch's key material changes.
val current = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching
val moved =
current.invite.copy(
communityRoot = entry.root,
rootEpoch = entry.rootEpoch,
controlPk = entry.controlPk,
relays = entry.relays,
)
account.client.publish(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays)
count++
}.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) }
}
@@ -289,25 +312,30 @@ class AccountConcordActions(
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays)
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo)
// Record the link so a later Refounding can refresh THIS coordinate rather than orphaning it
// (CORD-05, kind 13303). Shared with amy and Armada, so any of the creator's clients can.
publishConcordInviteList(
ConcordInviteListDocument(
entries =
listOf(
ConcordInviteListEntry(
token = minted.token.toHexKey(),
signerSk = minted.linkSignerPrivKey.toHexKey(),
communityId = entry.id,
url = minted.url,
createdAt = TimeUtils.now(),
// Record the link BEFORE handing the URL out (CORD-05, kind 13303). A link whose `signer_sk`
// was never stored can never be refreshed, so the next Refounding orphans it and everyone
// holding it is stranded — with nothing to have warned them. Failing the mint is the honest
// outcome; a stored entry for a link nobody received is harmless by comparison.
if (!publishConcordInviteList(
ConcordInviteListDocument(
entries =
listOf(
ConcordInviteListEntry(
token = minted.token.toHexKey(),
signerSk = minted.linkSignerPrivKey.toHexKey(),
communityId = entry.id,
url = minted.url,
createdAt = TimeUtils.now(),
),
),
),
),
publishTo,
)
),
)
) {
Log.w("Concord") { "Invite not minted for ${entry.id}: its link signer could not be recorded, so the link could never be refreshed" }
return null
}
if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo)
return minted.url
}
@@ -373,6 +401,32 @@ class AccountConcordActions(
return ConcordInviteResult.Joined(bundle.communityId)
}
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
// outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its
// unlock token forever — so without this the rotation meant to expel them hands them the new
// keys instead. `recoverStrandedConcordCommunities` has always been ban-gated; this is the
// other door into the same room.
//
// Fails CLOSED on an unreadable plane: the banlist is only knowable once the bundle yields
// the root, and no verdict means no join.
val joinKeys =
ConcordActions.controlPlaneKeys(
communityRoot = bundle.communityRoot.hexToByteArray(),
communityId = bundle.communityId.hexToByteArray(),
rootEpoch = bundle.rootEpoch,
controlPk = bundle.controlPk,
)
val joinRelays = bundle.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { relays }
val joinEditions =
ConcordActions.controlEditions(
account.client.fetchAll(filters = joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }),
joinKeys,
)
if (joinEditions.isEmpty()) return ConcordInviteResult.NotReachable
if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(account.signer.pubKey)) {
return ConcordInviteResult.Banned
}
val entry =
ConcordCommunityListEntry(
id = bundle.communityId,
@@ -960,14 +1014,13 @@ class AccountConcordActions(
// 5. 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.
adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
// 6. 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.
val moved =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId }
?.let { refreshConcordInviteLinks(it) } ?: 0
// 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.
val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0
Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" }
return true
}
@@ -1033,8 +1086,8 @@ class AccountConcordActions(
newEpoch: Long,
newControlPk: ByteArray? = null,
newControlRoot: ByteArray? = null,
) {
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return
): ConcordCommunityListEntry? {
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return null
// The rewrite itself — banking the leaving epoch's address for the anti-rollback floor,
// dropping stale control material on a legacy rotation, preserving invite_ref and residue —
// is shared with `amy` in [ConcordReceive.withAdoptedRoot]. Only the persist + publish and
@@ -1042,6 +1095,7 @@ class AccountConcordActions(
val next = ConcordReceive.withAdoptedRoot(entry, newRoot, newEpoch, newControlPk, newControlRoot)
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next))
announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null)
return next
}
/**
@@ -54,6 +54,15 @@ sealed interface ConcordInviteResult {
*/
data object Expired : ConcordInviteResult
/**
* The link opens, but this community's roster has banned us (CORD-04).
*
* A Refounding re-mints every outstanding link onto the new root, and a removed member keeps the
* URL and its unlock token forever so honouring the link alone would hand the new keys to the
* very account the rotation expelled.
*/
data object Banned : ConcordInviteResult
/**
* The bundle event was found but could not be opened with the link's token
* typically because it was minted by a newer/incompatible Concord client whose
@@ -124,6 +124,8 @@ fun ConcordInviteScreen(
RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false)
is ConcordInviteResult.Revoked ->
RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false)
is ConcordInviteResult.Banned ->
RedeemState.Failed(R.string.concord_invite_failed_banned, canRetry = false)
is ConcordInviteResult.Expired ->
RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false)
is ConcordInviteResult.NotReachable ->
+1
View File
@@ -321,6 +321,7 @@
<string name="concord_invite_failed_invalid">This invite link is invalid or can\'t be opened with this account.</string>
<string name="concord_invite_failed_incompatible">This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link.</string>
<string name="concord_invite_failed_revoked">This invite link has been revoked and can no longer be used. Ask for a new one.</string>
<string name="concord_invite_failed_banned">This community has removed you. The link still works, but its member list does not admit you.</string>
<string name="concord_invite_failed_expired">This invite link has expired and can no longer be used. Ask for a fresh link.</string>
<string name="concord_invite_preview_unknown_name">Community name is only revealed after you join</string>
<string name="concord_invite_preview_explainer">Joining connects to this invite\'s relays, publishes a join announcement signed by your account, and adds the community to your list. Nothing is sent until you tap Join.</string>
@@ -230,7 +230,7 @@ object ConcordCommands {
controlRoot = e.controlRoot ?: priorSameEpoch?.controlRoot ?: "",
generalChannelId = prior?.generalChannelId ?: "",
relays = e.relays,
heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") },
heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "", it.controlRoot ?: "") },
// Survives every merge: losing the anchor makes the NEXT exclusion
// unrecoverable, so a list entry without one must not clear ours.
inviteRef = e.inviteRef ?: prior?.inviteRef ?: "",
@@ -266,16 +266,13 @@ object ConcordCommands {
// (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 })
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays)
val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc))
RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it }
// Record the link in the CORD-05 Invite List (kind 13303) so any of this creator's
// clients — Amethyst, Armada — can later refresh THIS coordinate instead of orphaning
// the link at a dead epoch. That list is the liveness half of stranded recovery (A2).
publishInviteList(
ctx,
extraRelays = relaysFor(ctx, sc),
patch =
// 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
// it and every holder is stranded. Better to mint nothing than to hand out a link that
// is already doomed.
val recorded =
publishInviteList(
ctx,
ConcordInviteListDocument(
entries =
listOf(
@@ -288,7 +285,16 @@ object ConcordCommands {
),
),
),
)
)
if (!recorded) {
return Output.error(
"invite_unrecordable",
"could not record the link signer in your invite list (kind 13303), so this link could never be refreshed after a Refounding — not minting it",
)
}
val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc))
RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it }
Output.emit(
mapOf(
@@ -318,6 +324,34 @@ object ConcordCommands {
wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) }
?: return Output.error("not_found", "no valid bundle for this link").let { 1 }
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
// outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its
// unlock token forever — so without this check the rotation that was supposed to expel
// them hands them the new keys instead. `recover` has always been ban-gated; `join` is
// the other door into the same room.
//
// Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable
// after the bundle yields the root, which is why the check lives here rather than before.
val joinKeys =
ConcordActions.controlPlaneKeys(
communityRoot = bundle.communityRoot.hexToByteArray(),
communityId = bundle.communityId.hexToByteArray(),
rootEpoch = bundle.rootEpoch,
controlPk = bundle.controlPk,
)
val joinRelays = normalize(bundle.relays).ifEmpty { relays }
val joinEditions =
ConcordActions.controlEditions(
ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second },
joinKeys,
)
if (joinEditions.isEmpty()) {
return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join")
}
if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(ctx.signer.pubKey)) {
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(
StoredCommunity(
name = bundle.name,
@@ -401,7 +435,7 @@ object ConcordCommands {
rootEpoch = sc.rootEpoch,
controlPk = sc.controlPk.ifBlank { null },
controlRoot = sc.controlRoot.ifBlank { null },
heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }) },
heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }, it.controlRoot.ifBlank { null }) },
relays = sc.relays,
name = sc.name,
inviteRef = sc.inviteRef.ifBlank { null },
@@ -416,7 +450,7 @@ object ConcordCommands {
rootEpoch = entry.rootEpoch,
controlPk = entry.controlPk ?: "",
controlRoot = entry.controlRoot ?: "",
heldRoots = entry.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") },
heldRoots = entry.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "", it.controlRoot ?: "") },
relays = entry.relays,
name = entry.name.ifBlank { sc.name },
inviteRef = entry.inviteRef ?: sc.inviteRef,
@@ -585,36 +619,39 @@ object ConcordCommands {
* of every link they minted, so a rotation can refresh those links instead of orphaning them.
* Empty when none was ever published.
*/
suspend fun readInviteList(
ctx: Context,
extraRelays: Set<NormalizedRelayUrl> = emptySet(),
): ConcordInviteListDocument {
val relays = ctx.outboxRelays() + extraRelays
if (relays.isEmpty()) return ConcordInviteListDocument.EMPTY
suspend fun readInviteList(ctx: Context): ConcordInviteListDocument? {
val relays = ctx.outboxRelays()
if (relays.isEmpty()) return null
val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(ctx.signer.pubKey))
val newest =
ctx
.drain(relays.associateWith { listOf(filter) })
.map { it.second }
.maxByOrNull { it.createdAt }
return (newest as? ConcordInviteListEvent)?.decrypt(ctx.signer) ?: ConcordInviteListDocument.EMPTY
?: return ConcordInviteListDocument.EMPTY // nothing published yet — safe to start one
return (newest as? ConcordInviteListEvent)?.decrypt(ctx.signer)
}
/**
* Merges [patch] into the published list and republishes it. Read-merge-write rather than
* overwrite: the list is replaceable and per-creator, so two devices minting concurrently would
* otherwise delete each other's links (and their `signer_sk`, which is unrecoverable).
* Merges [patch] into the published list and republishes it, returning whether it landed.
*
* Read-merge-write, and **aborts rather than overwriting** when the read fails: kind 13303 is
* replaceable, so writing a patch-only document over a list we could not read deletes every
* other link's `signer_sk`. Those secrets cannot be regenerated, and losing one orphans its
* link at the next rotation, stranding everyone holding that URL.
*
* Account-scoped, like the coordinate itself (13303, me, "") is one list for every community,
* so reading or writing it on a single community's relays would fork it.
*/
suspend fun publishInviteList(
ctx: Context,
patch: ConcordInviteListDocument,
extraRelays: Set<NormalizedRelayUrl> = emptySet(),
) {
val relays = ctx.outboxRelays() + extraRelays
if (relays.isEmpty()) return
val merged = ConcordInviteList.merge(readInviteList(ctx, extraRelays), patch)
val event = ConcordInviteListEvent.create(ctx.signer, merged, TimeUtils.now())
ctx.publish(event, relays)
): Boolean {
val relays = ctx.outboxRelays()
if (relays.isEmpty()) return false
val base = readInviteList(ctx) ?: return false
val event = ConcordInviteListEvent.create(ctx.signer, ConcordInviteList.merge(base, patch), TimeUtils.now())
return ctx.publish(event, relays).values.any { it.accepted }
}
fun notFound(handle: String): Int {
@@ -33,7 +33,9 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
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.utils.RandomInstance
@@ -272,26 +274,37 @@ object ConcordModCommands {
// 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the
// new epoch — carries the ban. Each edition chains onto the updated banlist head.
var chain = editions
val banWraps = mutableListOf<Event>()
for (target in removed) {
val banWrap = ConcordModeration.ban(ctx.signer, cp, sc.communityId.hexToByteArray(), target, chain, TimeUtils.now(), owner = sc.owner)
ctx.publish(banWrap, relays)
val ack = ctx.publish(banWrap, relays)
if (ack.values.none { it.accepted }) {
return Output.error("ban_not_published", "the pre-rotation ban for $target was not accepted by any relay; refusing to refound with a banlist that would not survive")
}
banWraps += banWrap
chain = chain + (ConcordActions.controlEditions(listOf(banWrap), cp))
}
// 2. Everyone we are keeping. See the note above on why this reaches past the roster.
val recipients =
val candidates =
(rosterOf(authority) + guestbookMembersOf(ctx, sc) + channelAuthorsOf(ctx, sc, state) + me)
.mapTo(HashSet()) { it.lowercase() }
.apply {
removeAll(removed)
removeAll(authority.bannedMembers().map { it.lowercase() }.toSet())
}.toList()
}
val recipients = boundRecipients(candidates, authority)
// 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff
// get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one).
val newRoot = RandomInstance.bytes(32)
val newControlRoot = RandomInstance.bytes(32)
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
// Compact from what we KNOW the plane holds: the wraps we drained plus the bans we just
// published. Re-draining alone would race the relay's indexing, and a relay that has not
// yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch
// whose roster never banned the member we are removing.
val drained = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
val controlWraps = (drained + banWraps).distinctBy { it.id }
val build =
ConcordActions.buildRefounding(
rotatorSigner = ctx.signer,
@@ -335,33 +348,30 @@ object ConcordModCommands {
// Safe for every link because recovery is ban-gated at the epoch being left, and step 1
// banned everyone being removed — so a removed member's own `recover` is refused even
// though their link now resolves.
val refreshedInvite =
ConcordActions.inviteFor(
stored.communityId,
stored.owner,
stored.ownerSalt,
stored.root,
stored.rootEpoch,
stored.name,
stored.relays,
stored.controlPk.ifBlank { null },
)
val now = TimeUtils.now()
var refreshed = 0
for (link in ConcordCommands.readInviteList(ctx, relays).entries) {
val list = ConcordCommands.readInviteList(ctx)
val tombstoned = list?.tombstones?.mapTo(HashSet()) { it.token } ?: emptySet<String>()
for (link in list?.entries.orEmpty()) {
if (link.communityId != stored.communityId) continue
// An elapsed link can no longer be joined, so re-posting it would only resurrect a
// dead URL at a live epoch (CORD-05).
if (link.isExpired(now)) continue
// An elapsed or retired link can no longer be joined, so re-posting it would only
// resurrect a dead URL at a live epoch (CORD-05).
if (link.isExpired(now) || link.token in tombstoned) continue
runCatching {
val event =
ConcordActions.remintBundleAt(
linkSignerPrivKey = link.signerSk.hexToByteArray(),
token = link.token.hexToByteArray(),
invite = refreshedInvite,
createdAt = now,
val token = link.token.hexToByteArray()
// Refresh from the link's CURRENT bundle so its own fields — expiry, channel
// grants, icon, label — survive the rotation, and so a coordinate whose newest
// event is a revocation tombstone is left revoked instead of being re-opened.
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second }
val live = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching
val moved =
live.invite.copy(
communityRoot = stored.root,
rootEpoch = stored.rootEpoch,
controlPk = stored.controlPk.ifBlank { null },
relays = stored.relays,
)
ctx.publish(event, relays)
ctx.publish(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays)
refreshed++
}
}
@@ -382,6 +392,40 @@ object ConcordModCommands {
}
}
/**
* How many recipients one Refounding will re-key, mirroring Amethyst's own cap.
*
* Two thirds of the recipient union Guestbook joins and observed channel authors are
* attacker-writable: any key can announce a join or post once. Without a bound, padding those
* sets inflates the cost of the only hard removal Concord has until rotating becomes
* impractical, so the attack raises the price of its own remedy (B4 in the soft-ban audit).
*/
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
/**
* Caps [candidates], keeping the members whose standing is owner-rooted and therefore cannot be
* padded from outside. Anything dropped is reported rather than silently truncated a dropped
* member is stranded on the dead epoch and their only way back is `concord recover`.
*/
private fun boundRecipients(
candidates: Set<String>,
authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver,
): List<String> {
if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList()
val vouched = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() }
val kept = LinkedHashSet<String>()
candidates.filterTo(kept) { it in vouched }
for (candidate in candidates) {
if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break
kept.add(candidate)
}
val dropped = candidates.size - kept.size
if (dropped > 0) {
System.err.println("[concord] refounding recipient set trimmed to ${kept.size} of ${candidates.size}: $dropped member(s) will be stranded on the prior epoch")
}
return kept.toList()
}
/** Owner + everyone holding a role — owner-rooted, so it cannot be padded from outside. */
private fun rosterOf(authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver): Set<String> = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() }
@@ -61,6 +61,12 @@ data class StoredHeldRoot(
val root: String = "",
/** That epoch's Control Plane address; blank for a legacy, pre-split epoch (CORD-02 §5). */
val controlPk: String = "",
/**
* That epoch's staff write key, banked only if we held it. A relay that gates the prior epoch's
* Control Plane on NIP-42 AUTH as the stream key will not serve those wraps without it and
* those wraps are what rebuild the anti-rollback floor.
*/
val controlRoot: String = "",
)
/**
@@ -52,7 +52,7 @@ class InviteChannel(
* into a kind-33301 bundle (link invites) or a NIP-59 giftwrap (direct invites).
*/
@Serializable
class CommunityInvite(
data class CommunityInvite(
@SerialName("community_id") val communityId: String,
val owner: String,
@SerialName("owner_salt") val ownerSalt: String,
@@ -21,6 +21,10 @@
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
@@ -58,6 +62,12 @@ class ConcordInviteListEntry(
) {
/** True when this link can no longer be joined, so it must not be refreshed (CORD-05). */
fun isExpired(nowSecs: Long): Boolean = expiresAt != null && expiresAt <= nowSecs
/**
* The link signer's pubkey the addressable coordinate the bundle lives at, derived from the
* secret we kept. Refreshing or retiring a link means writing at exactly this author.
*/
fun signerPubKeyHex(): HexKey = KeyPair(privKey = signerSk.hexToByteArray()).pubKey.toHexKey()
}
/** A retired link: the creator's record that [token] is gone, kept so a merge cannot resurrect it. */
@@ -150,11 +160,15 @@ object ConcordInviteList {
private object WireDocumentSerializer : ExtrasPreserving<WireDocument>(WireDocument.serializer())
/**
* Decodes the plaintext document. A malformed document yields [ConcordInviteListDocument.EMPTY]
* rather than throwing but note the sharp edge this shape shares with the community list: one
* unparseable entry aborts the whole array, so every field defaults instead of being required.
* Decodes the plaintext document, or **null** when it cannot be parsed.
*
* Null rather than an empty document on purpose: this list is replaceable, so a caller that
* treats "I could not read it" as "it is empty" and republishes destroys every `signer_sk` it
* did not manage to read secrets that cannot be regenerated, orphaning every outstanding
* invite at a dead epoch. Callers MUST distinguish the two (see [ConcordInviteList.merge]'s
* callers). Each field still defaults, so one odd entry does not abort the whole array.
*/
fun decode(json: String): ConcordInviteListDocument =
fun decodeOrNull(json: String): ConcordInviteListDocument? =
try {
val doc = ConcordJson.instance.decodeFromString(WireDocumentSerializer, json)
ConcordInviteListDocument(
@@ -166,7 +180,7 @@ object ConcordInviteList {
residue = doc.extras,
)
} catch (_: Exception) {
ConcordInviteListDocument.EMPTY
null
}
fun encode(doc: ConcordInviteListDocument): String =
@@ -52,15 +52,19 @@ class ConcordInviteListEvent(
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/**
* Decrypts the whole document with [signer] entries, tombstones and the document residue.
* Use this (never a partial read) whenever the result will be re-encoded, or another client's
* unknown keys are dropped on the next publish.
* Decrypts the whole document with [signer] entries, tombstones and the document residue or
* **null** if it cannot be decrypted or parsed.
*
* Null, never empty: a caller that reads a decrypt failure as "no links yet" and republishes
* wipes every `signer_sk` on this replaceable coordinate. A bunker signer that momentarily
* refuses is enough to trigger it. Use this (never a partial read) whenever the result will be
* re-encoded, or another client's unknown keys are dropped on the next publish.
*/
suspend fun decrypt(signer: NostrSigner): ConcordInviteListDocument =
suspend fun decrypt(signer: NostrSigner): ConcordInviteListDocument? =
try {
ConcordInviteList.decode(signer.nip44Decrypt(content, signer.pubKey))
ConcordInviteList.decodeOrNull(signer.nip44Decrypt(content, signer.pubKey))
} catch (_: Exception) {
ConcordInviteListDocument.EMPTY
null
}
companion object {
@@ -46,7 +46,7 @@ class ConcordInviteListTest {
@Test
fun readsTheSpecDocumentIntoTypedEntries() {
val doc = ConcordInviteList.decode(specJson)
val doc = ConcordInviteList.decodeOrNull(specJson)!!
assertEquals(1, doc.entries.size)
val e = doc.entries.first()
@@ -65,7 +65,7 @@ class ConcordInviteListTest {
@Test
fun emitsTheSnakeCaseKeysAnotherClientReads() {
val json = ConcordInviteList.encode(ConcordInviteList.decode(specJson))
val json = ConcordInviteList.encode(ConcordInviteList.decodeOrNull(specJson)!!)
// Field names are the interop contract — a camelCase slip silently orphans every link.
for (key in listOf("\"token\"", "\"signer_sk\"", "\"community_id\"", "\"url\"", "\"created_at\"", "\"expires_at\"", "\"entries\"", "\"tombstones\"")) {
assertTrue(json.contains(key), "missing wire key $key")
@@ -84,7 +84,7 @@ class ConcordInviteListTest {
"doc_level_unknown": 7 }
""".trimIndent()
val round = ConcordInviteList.encode(ConcordInviteList.decode(withExtras))
val round = ConcordInviteList.encode(ConcordInviteList.decodeOrNull(withExtras)!!)
assertTrue(round.contains("future_field"), "entry-level unknown key dropped")
assertTrue(round.contains("doc_level_unknown"), "document-level unknown key dropped")
@@ -116,9 +116,36 @@ class ConcordInviteListTest {
}
@Test
fun aMalformedDocumentYieldsEmptyRatherThanThrowing() {
assertEquals(0, ConcordInviteList.decode("not json").entries.size)
assertEquals(0, ConcordInviteList.decode("{\"entries\":\"wrong type\"}").entries.size)
fun aMalformedDocumentYieldsNullSoCallersCannotOverwriteWithIt() {
// Null, not empty: a caller that republishes an "empty" list over this replaceable
// coordinate destroys every signer_sk it failed to read.
assertEquals(null, ConcordInviteList.decodeOrNull("not json"))
assertEquals(null, ConcordInviteList.decodeOrNull("{\"entries\":\"wrong type\"}"))
}
@Test
fun anUnreadableListIsDistinguishableFromAnEmptyOne() {
// The whole point of the null: a caller must be able to tell "I could not read it" from
// "there is nothing in it". Publishing a merge onto the latter is fine; onto the former it
// destroys every signer_sk on this replaceable coordinate.
assertEquals(null, ConcordInviteList.decodeOrNull("<not json>"))
val empty = ConcordInviteList.decodeOrNull("""{"entries":[],"tombstones":[]}""")
assertEquals(0, empty!!.entries.size, "a genuinely empty list decodes, it does not fail")
// And a merge onto an empty base keeps the patch, so starting a first list still works.
val patch = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t", "sk", "c", "u")))
assertEquals(listOf("t"), ConcordInviteList.merge(empty, patch).entries.map { it.token })
}
@Test
fun theSignerPubKeyIsTheCoordinateTheBundleLivesAt() {
// Refreshing or revoking a link means writing at exactly this author, so it must derive from
// the secret we kept rather than being stored (and drifting) separately.
val sk = "11".repeat(32)
val entry = ConcordInviteListEntry("t", sk, "c", "u")
assertEquals(64, entry.signerPubKeyHex().length)
assertEquals(entry.signerPubKeyHex(), ConcordInviteListEntry("t2", sk, "c", "u2").signerPubKeyHex())
}
@Test