From 05a331068f81f053908d84b36d79bd841feba657 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 17:51:39 -0400 Subject: [PATCH 01/12] test(concord): pin that a banned member's typing heartbeat is dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The soft-ban audit's A4 shipped with a send-side guard and a receive-side filter, and the receive-side filter — the only one that binds a modified client — had no test. Bans first, so the assertion exercises the filter rather than an entry that was seated before the ban, and checks the filter is targeted rather than a blanket mute. Mutation-tested: removing the isBanned check in ConcordCommunitySession.ingestTyping fails it. Co-Authored-By: Claude Opus 5 (1M context) --- .../model/concord/ConcordBannedTypingTest.kt | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordBannedTypingTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordBannedTypingTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordBannedTypingTest.kt new file mode 100644 index 0000000000..a89e26789e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordBannedTypingTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The receive half of the soft-ban audit's A4: a banned member's typing heartbeat must not reach + * the "… is typing" row. The send half is a guard in the app's own action layer, which a malicious + * or modified client simply won't run — so this filter, on the receive side, is the only one that + * actually protects the room. It shipped without a test; this is it. + */ +class ConcordBannedTypingTest { + private val owner = NostrSignerInternal(KeyPair()) + private val troll = NostrSignerInternal(KeyPair()) + private val regular = NostrSignerInternal(KeyPair()) + + private fun entryFor(community: NewConcordCommunity) = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + @Test + fun dropsABannedMembersTypingHeartbeatAndKeepsEveryoneElses() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) + community.genesisWraps.forEach { session.ingest(it) } + + val channelId = community.generalChannelIdHex + val plane = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch) + val now = TimeUtils.now() + + // Ban first, so what follows tests the filter rather than an entry seated before the ban. + // The banlist edition folds through the Control Plane exactly as it would on the wire. + session.ingest( + ConcordModeration.ban( + actor = owner, + controlPlane = session.controlPlaneKeys(), + communityId = community.communityIdHex.hexToByteArray(), + member = troll.pubKey, + current = session.controlEditions(), + createdAt = now, + owner = community.ownerPubKey, + ), + ) + assertTrue( + session.state.value + ?.authority + ?.isBanned(troll.pubKey) == true, + "the ban must have folded before the heartbeats are judged", + ) + + // The banned member keeps broadcasting — a modified client ignores the send-side guard. + session.ingest(ConcordActions.buildChannelTyping(troll, plane, channelId, community.rootEpoch, now)) + assertEquals( + null, + session.typing.value[channelId]?.get(troll.pubKey.lowercase()), + "a banned member must never be seated in the typing row", + ) + + // The filter is targeted, not a blanket mute: an ordinary member still types normally. + session.ingest(ConcordActions.buildChannelTyping(regular, plane, channelId, community.rootEpoch, now)) + assertTrue( + session.typing.value[channelId]?.containsKey(regular.pubKey.lowercase()) == true, + "an unbanned member's typing heartbeat must still show", + ) + assertEquals( + null, + session.typing.value[channelId]?.get(troll.pubKey.lowercase()), + "seating one member must not drag the banned one in", + ) + } +} From 3153942bac5a007e65d95d7f4d28517f9102a2a6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 18:03:28 -0400 Subject: [PATCH 02/12] feat(cli): let amy adopt the Control Plane write key a Grant delivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A promotion to staff delivers the `control_root` inside the Grant edition itself (CORD-04 §3). Amethyst drains that on its Concord revision tick, but that logic lived only in `AccountConcordActions`, so `amy` could hold a rank it could never write under: the fold seated it as staff and every moderation verb still refused with `forbidden`. That is also why #3873's delivery path shipped without a CLI test — the harness could not accept a promotion. Extracts the decision into `commons` as `ConcordReceive`, pure and shared: - `deliveredControlRoot` — the whole fail-closed check (are we staff by our OWN fold, does a Grant carry a wrap, does it open under the pairwise key, name this epoch, and derive to the `control_pk` we already hold). - `withAdoptedRoot` — the entry rewrite a base rotation produces, banking the leaving epoch's address for the anti-rollback floor. - `isAuthorizedRotator` — the ban-aware rotator check. Amethyst now calls the shared versions (no behaviour change; its persist + publish and Guestbook re-announce stay put). amy adopts during the Control Plane drain every moderation command already performs, since it has no tick of its own, and returns the refreshed record so a freshly promoted staffer can pass the secret on in its own Grant. Adoption is local to amy's store on purpose: Amethyst republishes the kind-13302 list so a user's other devices follow, and doing that here would mean rebuilding and signing the whole list from the CLI. Verified end to end against a loopback geode: bob is refused before the promotion, alice promotes him, bob's stored `control_root` is blank, his next command adopts and persists it, and his BAN lands and is honored by alice's independent fold. A role edition he lacks MANAGE_ROLES for is still dropped on fold — possession remains a spam gate, never authority. Still Android-only: `recoverStrandedConcordCommunities`, which needs invite re-resolution over the network. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/model/AccountConcordActions.kt | 73 ++------- .../amethyst/cli/commands/ConcordCommands.kt | 42 +++++ .../cli/commands/ConcordModCommands.kt | 46 +++++- .../commons/actions/ConcordReceive.kt | 145 ++++++++++++++++++ 4 files changed, 237 insertions(+), 69 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index f659e38564..4caf607709 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession @@ -35,17 +36,12 @@ import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity -import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions -import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind -import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap -import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary -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 @@ -932,35 +928,11 @@ class AccountConcordActions( newControlRoot: ByteArray? = null, ) { if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return - // The epoch we're leaving is banked with the address it was folded at, so its Control - // Plane stays subscribable for the anti-rollback floor (a split epoch's address can - // never be re-derived, only remembered — CORD-02 §2). - val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch } - val next = - ConcordCommunityListEntry( - id = entry.id, - owner = entry.owner, - ownerSalt = entry.ownerSalt, - root = newRoot.toHexKey(), - rootEpoch = newEpoch, - // A rotation that delivered no control material is a legacy, pre-split one - // (CORD-06 §3): the new epoch keeps folding at the legacy address, and the - // stale prior-epoch values must NOT be carried into it. - controlPk = newControlPk?.toHexKey(), - controlRoot = newControlRoot?.toHexKey(), - heldRoots = held, - privateChannels = entry.privateChannels, - relays = entry.relays, - name = entry.name, - addedAt = entry.addedAt, - // The invite_ref anchor must survive a rotation, or the *next* Refounding we're left - // out of would be unrecoverable. - inviteRef = entry.inviteRef, - excludedAtEpoch = entry.excludedAtEpoch, - // Unknown keys another client wrote (Armada's list is `[k: string]: unknown`) - // must survive our rotation write, or we delete their data on every rekey. - residue = entry.residue, - ) + // 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 + // the Guestbook re-announce below are Android's. + val next = ConcordReceive.withAdoptedRoot(entry, newRoot, newEpoch, newControlPk, newControlRoot) account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next)) announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) } @@ -1030,39 +1002,16 @@ class AccountConcordActions( */ internal suspend fun drainConcordStaffGrants() { if (!account.isWriteable()) return - val me = account.signer.pubKey.lowercase() for (session in account.concordSessions.sessions()) { val entry = session.entry - // Already staff at this epoch, or a legacy community with no split to join. - val heldControlPk = entry.controlPk - if (entry.controlRoot != null || heldControlPk == null) continue val state = session.state.value ?: continue - // Only a Grant our fold honors can deliver: an unauthorized edition hands us nothing. - if (!state.authority.isStaff(me)) continue - - val myGrantCoordinate = - ConcordKeyDerivation - .grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray()) - .toHexKey() - val delivered = - session - .controlEditions() - .filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate } - // Newest first: a re-issued Grant (a lost key, a head superseded before we - // fetched it) carries the fresher wrap. - .sortedByDescending { it.version } - .firstNotNullOfOrNull { edition -> - val wrap = ConcordJson.decodeOrNull(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null - val opened = ControlRootWrap.openOrNull(wrap, account.signer, edition.author) ?: return@firstNotNullOfOrNull null - if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null - // Fails closed: a secret that doesn't derive to the pk we hold is dropped, - // never adopted — we will not split ourselves off from the plane's readers. - if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null - opened.controlRoot - } ?: continue + // The whole decision — are we staff, does a Grant carry a wrap, does it open, name our + // epoch, and derive to the control_pk we hold — is shared with `amy` in + // [ConcordReceive.deliveredControlRoot]. Only the persist + publish below is Android's. + val delivered = ConcordReceive.deliveredControlRoot(entry, session.controlEditions(), state.authority, account.signer) ?: continue account.sendMyPublicAndPrivateOutbox( - account.concordChannelList.follow(entry.withControlRoot(delivered.toHexKey())), + account.concordChannelList.follow(entry.withControlRoot(delivered)), ) } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index ff151176ef..689b0f18ee 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -28,7 +28,12 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -316,6 +321,43 @@ object ConcordCommands { controlRoot = sc.controlRoot.ifBlank { null }, ) + /** + * Adopts the `control_root` a staff-making Grant delivered to us (CORD-04 §3), persisting it to + * the local store and returning the now-writable keys — or null when nothing was delivered. + * + * The decision itself is [ConcordReceive.deliveredControlRoot], shared with Amethyst: it fails + * closed unless our own fold seats us as staff, the wrap opens under the granter↔member pairwise + * key, it names this epoch, and the secret derives to exactly the `control_pk` we already hold. + * + * Local-only on purpose: Amethyst republishes the kind-13302 list on adoption so a user's other + * devices follow, and doing that here would need amy to rebuild and sign the whole list. A CLI + * adoption therefore unblocks *this* account's writes; other devices adopt from their own fold. + */ + suspend fun adoptDeliveredControlRoot( + ctx: Context, + dataDir: DataDir, + sc: StoredCommunity, + editions: List, + ): Pair? { + val entry = + ConcordCommunityListEntry( + id = sc.communityId, + owner = sc.owner, + ownerSalt = sc.ownerSalt, + root = sc.root, + rootEpoch = sc.rootEpoch, + controlPk = sc.controlPk.ifBlank { null }, + controlRoot = sc.controlRoot.ifBlank { null }, + relays = sc.relays, + name = sc.name, + ) + val authority = AuthorityResolver.resolve(editions, sc.owner) + val delivered = ConcordReceive.deliveredControlRoot(entry, editions, authority, ctx.signer) ?: return null + val updated = sc.copy(controlRoot = delivered) + ConcordStore(dataDir.concordFile).upsert(updated) + return updated to controlPlaneKeysFor(updated) + } + fun notFound(handle: String): Int { Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 3633b24dbe..a2d364e8ec 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -51,7 +51,7 @@ object ConcordModCommands { val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) Context.open(dataDir).use { ctx -> ctx.prepare() - val (_, editions) = load(ctx, sc) + val (_, editions) = load(ctx, sc, dataDir) val state = ConcordCommunityState.fold(editions, sc.owner) Output.emit( mapOf( @@ -92,7 +92,7 @@ object ConcordModCommands { Context.open(dataDir).use { ctx -> ctx.prepare() - val (cp, editions) = load(ctx, sc) + val (cp, editions) = load(ctx, sc, dataDir) writeGuard(cp)?.let { return it } val roleId = RandomInstance.bytes(32) val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire()) @@ -119,7 +119,8 @@ object ConcordModCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val member = ctx.requireUserHex(userRef) - val (cp, editions) = load(ctx, sc) + val loaded = load(ctx, sc, dataDir) + val (cp, editions) = loaded writeGuard(cp)?.let { return it } // A Grant that first makes its member staff must carry the write secret in the same // edition (CORD-04 §3); ConcordModeration wraps it pairwise when the granted roles @@ -134,7 +135,10 @@ object ConcordModCommands { current = editions, createdAt = TimeUtils.now(), owner = sc.owner, - controlRoot = sc.controlRoot.ifBlank { null }?.hexToByteArray(), + controlRoot = + loaded.community.controlRoot + .ifBlank { null } + ?.hexToByteArray(), epoch = sc.rootEpoch, ) val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) @@ -170,7 +174,7 @@ object ConcordModCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val member = ctx.requireUserHex(userRef) - val (cp, editions) = load(ctx, sc) + val (cp, editions) = load(ctx, sc, dataDir) writeGuard(cp)?.let { return it } val cid = sc.communityId.hexToByteArray() val wrap = @@ -186,11 +190,28 @@ object ConcordModCommands { } } + /** + * The drained Control Plane: the community as stored *after* any adoption, its keys, and the + * editions to chain onto. [community] matters because adopting a delivered `control_root` + * rewrites the stored record — a caller that kept the pre-load copy would then fail to pass the + * secret on in its own Grant (CORD-04 §3). + */ + private class LoadedControl( + val community: StoredCommunity, + val keys: ControlPlaneKeys, + val editions: List, + ) { + operator fun component1() = keys + + operator fun component2() = editions + } + /** Drain the control plane and return its keys + current editions to chain onto. */ private suspend fun load( ctx: Context, sc: StoredCommunity, - ): Pair> { + dataDir: DataDir? = null, + ): LoadedControl { val cp = ConcordCommands.controlPlaneKeysFor(sc) val relays = ConcordCommands.relaysFor(ctx, sc) // Concord relays serve the plane's kind-1059 only to a connection AUTHed as the stream @@ -198,7 +219,18 @@ object ConcordModCommands { // that secret is staff-only (CORD-02 §2), and a member simply has nothing to register. ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } - return cp to ConcordActions.controlEditions(wraps, cp) + val editions = ConcordActions.controlEditions(wraps, cp) + + // A promotion to staff delivers the Control Plane write key inside the Grant itself + // (CORD-04 §3), so the fold that seats the role is also when the key arrives. Amethyst + // drains this on its revision tick; amy has no tick, so the fold a command already does is + // the moment to adopt — otherwise a CLI-promoted staffer holds a rank it can never write + // under. Same shared, fail-closed check both clients use. + if (dataDir != null && !cp.canWrite) { + val adopted = ConcordCommands.adoptDeliveredControlRoot(ctx, dataDir, sc, editions) + if (adopted != null) return LoadedControl(adopted.first, adopted.second, ConcordActions.controlEditions(wraps, adopted.second)) + } + return LoadedControl(sc, cp, editions) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt new file mode 100644 index 0000000000..b807ae24de --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap +import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner + +/** + * The **receive** half of Concord's key lifecycle, as pure functions: what a client must adopt + * when a Grant hands it the Control Plane write key (CORD-04 §3), and how an entry is rewritten + * when a base rotation moves the community to a new epoch (CORD-06). + * + * These lived only in Amethyst's `AccountConcordActions`, which meant a headless client (`amy`) + * could hold a rank it could never write under, and could not follow a Refounding at all. The + * logic is platform-agnostic — the only Android-shaped parts were the persistence and publish, + * which stay with the caller. Every function here decides *what* to adopt and returns it; the + * caller owns storing it and republishing the kind-13302 list. + * + * Everything fails closed: an undecryptable, mis-epoched or non-deriving delivery yields null, + * never a partially-adopted entry. + */ +object ConcordReceive { + /** + * The `control_root` a staff-making Grant delivered to the account behind [recipientSigner], + * or null when there is nothing to adopt (CORD-04 §3). + * + * Gated three ways, each of which fails closed: + * - only a Grant **our own fold honors** can deliver, so [authority] must already seat us as + * staff — a rogue cannot feed us a key by minting an edition nobody accepts; + * - the wrap must open under the granter↔member pairwise key, and name [entry]'s epoch, + * because compaction re-wraps a Grant head verbatim across Refoundings and a folded head + * can legitimately carry a wrap minted for a prior epoch; + * - the secret must derive to exactly the `control_pk` we already hold, or adopting it would + * split us off from the plane's readers. + * + * Returns null (not an error) when the entry already holds the secret, holds no `control_pk` + * to check against (a legacy pre-split community), or when we are not staff. + */ + suspend fun deliveredControlRoot( + entry: ConcordCommunityListEntry, + editions: List, + authority: AuthorityResolver, + recipientSigner: NostrSigner, + ): HexKey? { + val heldControlPk = entry.controlPk + if (entry.controlRoot != null || heldControlPk == null) return null + val me = recipientSigner.pubKey.lowercase() + if (!authority.isStaff(me)) return null + + val myGrantCoordinate = + ConcordKeyDerivation + .grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray()) + .toHexKey() + + return editions + .filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate } + // Newest first: a re-issued Grant (a lost key, a head superseded before we fetched it) + // carries the fresher wrap. + .sortedByDescending { it.version } + .firstNotNullOfOrNull { edition -> + val wrap = ConcordJson.decodeOrNull(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null + val opened = ControlRootWrap.openOrNull(wrap, recipientSigner, edition.author) ?: return@firstNotNullOfOrNull null + if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null + if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null + opened.controlRoot.toHexKey() + } + } + + /** + * Whether [rotator] was allowed to launch the base rotation that [entry] is being moved by + * (CORD-06). `hasPermission`, never `effectivePermissions`: the latter ignores the banlist, so + * a banned BAN-holder could rotate the whole community out from under it. + */ + fun isAuthorizedRotator( + authority: AuthorityResolver, + rotator: HexKey, + ): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN) + + /** + * 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. + * + * The epoch being left is banked in `heldRoots` **with the address it was folded at**, because + * a split epoch's Control address can never be re-derived, only remembered (CORD-02 §2) — that + * banked address is what keeps the anti-rollback floor rebuildable. A rotation that delivered + * no control material is a legacy pre-split one (CORD-06 §3): the new epoch folds at the legacy + * address, and the stale prior-epoch values must NOT be carried into it. `inviteRef` survives, + * or the *next* Refounding we are left out of becomes unrecoverable; `residue` survives, or we + * delete another client's unknown keys on every rekey. + */ + fun withAdoptedRoot( + entry: ConcordCommunityListEntry, + newRoot: ByteArray, + newEpoch: Long, + newControlPk: ByteArray? = null, + newControlRoot: ByteArray? = null, + ): ConcordCommunityListEntry = + ConcordCommunityListEntry( + id = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + controlPk = newControlPk?.toHexKey(), + controlRoot = newControlRoot?.toHexKey(), + heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }, + privateChannels = entry.privateChannels, + relays = entry.relays, + name = entry.name, + addedAt = entry.addedAt, + inviteRef = entry.inviteRef, + excludedAtEpoch = entry.excludedAtEpoch, + residue = entry.residue, + ) +} From 4022a6a5da0fc08203c12bdc270e82fb49a33e65 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 18:37:05 -0400 Subject: [PATCH 03/12] feat(cli): add `amy concord recover` for stranded-recovery (CORD-05/06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the CLI's Concord receive path. A Refounding carries only `(newRoot, newEpoch, rotator)` and no recipient list, so a member simply left out of the rekey receives nothing and sits on the dead epoch forever while everyone else moves on. There is no message to miss, which is why the rekey drain cannot help: the only way back is the invite link the membership was joined through, since the community keeps re-minting its bundle at the same addressable coordinate. amy never stored that anchor, so recovery was impossible in principle. Adds `inviteRef` to the stored record, populated on `join` (bare, domain-agnostic) and carried through `import` — backstopped by what we already held, because a list entry without one must not clear ours or the NEXT exclusion becomes unrecoverable. Recovery is an explicit verb rather than Amethyst's timer sweep, so it stays deterministic and scriptable. Each community reports why it did or didn't move: `no_invite_ref`, `bad_invite_ref`, `no_live_bundle`, `banned`, `already_current`, `control_plane_not_folded`, or the epoch it advanced to. The ban gate is the part that matters (A2 in docs/concord-soft-ban-audit.md): a removed member keeps the link's unlock token forever, so without it this walks them straight back into the epoch they were rotated out of. It reads the banlist of the epoch being LEFT — the last plane we can still fold — and fails closed: a plane that will not fold yields no verdict and is skipped, never recovered. Verified against a loopback geode: a current member gets `already_current`, a community with no anchor gets `no_invite_ref`, and a member banned at the current epoch is refused with `banned`. The merge-forward itself is quartz's `ConcordStrandedRecovery` (already unit-tested); it is not exercised live here because amy cannot perform a Refounding to strand anyone with. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/cli/commands/ConcordCommands.kt | 154 ++++++++++++++++-- .../amethyst/cli/stores/ConcordStore.kt | 5 + 2 files changed, 146 insertions(+), 13 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 689b0f18ee..457be94472 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -31,8 +31,10 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -61,6 +63,9 @@ object ConcordCommands { | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link | concord join URL redeem an invite link and save the community + | concord recover [COMMUNITY] re-resolve the joined-through invite link and + | follow a Refounding we were left out of + | (CORD-06); refuses if that epoch banned us | concord roles COMMUNITY list live roles + current banlist (CORD-04) | concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK) | concord grant COMMUNITY USER ROLE-ID grant a role to a member @@ -75,7 +80,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -87,6 +92,7 @@ object ConcordCommands { "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, "invite" to { rest -> invite(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, + "recover" to { rest -> recover(dataDir, rest) }, "roles" to { rest -> ConcordModCommands.roles(dataDir, rest) }, "role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) }, "grant" to { rest -> ConcordModCommands.grant(dataDir, rest) }, @@ -214,6 +220,9 @@ object ConcordCommands { generalChannelId = prior?.generalChannelId ?: "", relays = e.relays, heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") }, + // 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 ?: "", ), ) mapOf( @@ -289,6 +298,9 @@ object ConcordCommands { // community is still pre-split and folds at the legacy address. controlPk = bundle.controlPk ?: "", relays = bundle.relays, + // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving + // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. + inviteRef = ConcordActions.bareInviteRef(url) ?: "", ), ) Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays)) @@ -339,18 +351,7 @@ object ConcordCommands { sc: StoredCommunity, editions: List, ): Pair? { - val entry = - ConcordCommunityListEntry( - id = sc.communityId, - owner = sc.owner, - ownerSalt = sc.ownerSalt, - root = sc.root, - rootEpoch = sc.rootEpoch, - controlPk = sc.controlPk.ifBlank { null }, - controlRoot = sc.controlRoot.ifBlank { null }, - relays = sc.relays, - name = sc.name, - ) + val entry = entryFor(sc) val authority = AuthorityResolver.resolve(editions, sc.owner) val delivered = ConcordReceive.deliveredControlRoot(entry, editions, authority, ctx.signer) ?: return null val updated = sc.copy(controlRoot = delivered) @@ -358,6 +359,133 @@ object ConcordCommands { return updated to controlPlaneKeysFor(updated) } + /** The quartz list entry a [StoredCommunity] describes — the shape every commons helper takes. */ + fun entryFor(sc: StoredCommunity) = + ConcordCommunityListEntry( + id = sc.communityId, + owner = sc.owner, + ownerSalt = sc.ownerSalt, + root = sc.root, + 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 }) }, + relays = sc.relays, + name = sc.name, + inviteRef = sc.inviteRef.ifBlank { null }, + ) + + /** Folds [entry] back into the stored shape after a rotation is adopted. */ + fun storedFrom( + sc: StoredCommunity, + entry: ConcordCommunityListEntry, + ) = sc.copy( + root = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk ?: "", + controlRoot = entry.controlRoot ?: "", + heldRoots = entry.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") }, + relays = entry.relays, + name = entry.name.ifBlank { sc.name }, + inviteRef = entry.inviteRef ?: sc.inviteRef, + ) + + /** + * `concord recover [COMMUNITY]` — the stranded-recovery receive path (CORD-05/06 A2). + * + * A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a + * member simply left out of the rekey receives nothing and sits on the dead epoch forever while + * everyone else moves on. There is no message to miss, which is why the rekey drain cannot help. + * The way back is the invite link the membership was joined through: the community keeps + * re-minting its bundle at the same addressable coordinate, so a live bundle at a **strictly + * higher** epoch than ours proves we were left behind — and carries the new root. + * + * Amethyst sweeps this on a timer; amy makes it an explicit verb, so it stays deterministic and + * scriptable rather than a background loop. + * + * The ban gate is the point of care. A removed member keeps the link's unlock token forever, so + * without it this walks them straight back into the epoch they were rotated out of. It reads the + * banlist of the epoch we are **leaving** (the last Control Plane we can still fold) and **fails + * closed**: a community whose plane will not fold yields no verdict and is skipped, never + * recovered. + */ + private suspend fun recover( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positionalOrNull(0) + args.rejectUnknown() + val store = ConcordStore(dataDir.concordFile) + val targets = + if (handle != null) { + listOf(store.find(handle) ?: return notFound(handle)) + } else { + store.load() + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val results = mutableListOf>() + for (sc in targets) { + val inviteRef = sc.inviteRef.ifBlank { null } + if (inviteRef == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_invite_ref") + continue + } + val parsed = ConcordActions.parseInviteLink(inviteRef) + if (parsed == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "bad_invite_ref") + continue + } + val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() } + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second } + // Only a LIVE bundle recovers: an expired or revoked link is not a rotation we missed. + val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite + if (bundle == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_live_bundle") + continue + } + + // Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict, + // no recovery — the gate fails closed rather than assuming "not banned". + val cp = controlPlaneKeysFor(sc) + ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) + val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + val editions = ConcordActions.controlEditions(controlWraps, cp) + if (editions.isEmpty()) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "control_plane_not_folded") + continue + } + val bannedHere = AuthorityResolver.resolve(editions, sc.owner).isBanned(ctx.signer.pubKey) + + val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere) + if (merged == null) { + results += + mapOf( + "community_id" to sc.communityId, + "name" to sc.name, + "recovered" to false, + "reason" to if (bannedHere) "banned" else "already_current", + "root_epoch" to sc.rootEpoch, + ) + continue + } + store.upsert(storedFrom(sc, merged)) + results += + mapOf( + "community_id" to sc.communityId, + "name" to sc.name, + "recovered" to true, + "from_epoch" to sc.rootEpoch, + "root_epoch" to merged.rootEpoch, + ) + } + Output.emit(mapOf("communities" to results)) + return 0 + } + } + fun notFound(handle: String): Int { Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index 7ae731a877..d2de85a258 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -48,6 +48,11 @@ data class StoredCommunity( // Past access roots kept per epoch (CORD-06 Refounding rotates the root). Lets `read --epoch ` // re-derive a prior epoch's Chat Plane to reach pre-refounding history. Populated by `import`. val heldRoots: List = emptyList(), + // The bare `#` invite this membership was joined through — the stranded-recovery + // anchor (CORD-05/06). A Refounding carries no recipient list, so a member simply left out of the + // rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct + // invite or a community joined before amy stored it. + val inviteRef: String = "", ) /** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */ From 0aa3adfc07d26daa9047ec13cde57685a0ca4dbd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 18:48:28 -0400 Subject: [PATCH 04/12] =?UTF-8?q?feat(cli):=20add=20`amy=20concord=20refou?= =?UTF-8?q?nd`=20+=20`rekey`=20=E2=80=94=20the=20rotation=20half=20of=20CO?= =?UTF-8?q?RD-06?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refound` is the hard removal a ban cannot give: a ban only strips standing, while the removed member keeps every key they ever held. Rotating the `community_root` — and, since CORD-02 §2, a fresh `control_root` beside it so a demoted staffer's retained secret dies with the epoch — is what actually closes the room. The compacted Control Plane is re-sealed at the new epoch and each retained member gets a rekey blob. Authority mirrors Amethyst exactly: `hasPermission`, never `effectivePermissions`, so a banned BAN-holder cannot launch one; the owner is never a valid target; and removal takes the same rank rule as a ban (CORD-04 §3) — an admin cannot Refound a peer admin out. The recipient set reaches past the roster to the Guestbook AND the authors of every channel message we can decrypt, because a member who only ever posted holds no role and files no Guestbook motion — building the set without them silently expels them. It is still a floor, not a census. `rekey` is the receive half, and without it `refound` was actively harmful from the CLI: a retained member's blob sat on the relay unopened, so a Refounding launched from amy stranded every other amy member. It authorizes the rotator against the roster of the epoch being LEFT and fails closed. Verified end to end against a loopback geode — the full cycle, which was not previously expressible from the CLI at all: alice refound --remove → epoch 0 → 1, recipients=2 (bob is kept because he POSTED, holding no role — the author harvest) bob rekey → epoch 0 → 1, same root + control_pk bob sends, alice reads it at the new epoch alice roles → the removed member is banned Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/cli/commands/ConcordCommands.kt | 71 +++++++- .../cli/commands/ConcordModCommands.kt | 171 ++++++++++++++++++ 2 files changed, 241 insertions(+), 1 deletion(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 457be94472..1e299b4191 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -63,6 +63,8 @@ object ConcordCommands { | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link | concord join URL redeem an invite link and save the community + | concord rekey [COMMUNITY] follow a Refounding we were re-keyed for: + | open our blob and adopt the new epoch | concord recover [COMMUNITY] re-resolve the joined-through invite link and | follow a Refounding we were left out of | (CORD-06); refuses if that epoch banned us @@ -71,6 +73,9 @@ object ConcordCommands { | concord grant COMMUNITY USER ROLE-ID grant a role to a member | concord ban COMMUNITY USER ban a member | concord unban COMMUNITY USER unban a member + | concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the + | control_root) so removed members lose every + | key — the hard removal a ban cannot give """.trimMargin() suspend fun dispatch( @@ -80,7 +85,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -93,11 +98,13 @@ object ConcordCommands { "invite" to { rest -> invite(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, "recover" to { rest -> recover(dataDir, rest) }, + "rekey" to { rest -> rekey(dataDir, rest) }, "roles" to { rest -> ConcordModCommands.roles(dataDir, rest) }, "role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) }, "grant" to { rest -> ConcordModCommands.grant(dataDir, rest) }, "ban" to { rest -> ConcordModCommands.ban(dataDir, rest) }, "unban" to { rest -> ConcordModCommands.unban(dataDir, rest) }, + "refound" to { rest -> ConcordModCommands.refound(dataDir, rest) }, ), ) @@ -486,6 +493,68 @@ object ConcordCommands { } } + /** + * `concord rekey [COMMUNITY]` — follow a Refounding we WERE re-keyed for (CORD-06). + * + * The normal counterpart to [recover]: a retained member gets a per-recipient blob on the next + * epoch's base-rekey plane, and opening it yields the new root. Amethyst drains this on its + * revision tick; amy has no tick, so it is a verb. Without it a Refounding launched from the CLI + * strands every other CLI member even though their blob is sitting on the relay. + * + * The rotator is authorized against the roster of the epoch being **left** — `hasPermission`, + * never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed: + * a plane that will not fold yields no verdict and the community is skipped. + */ + private suspend fun rekey( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positionalOrNull(0) + args.rejectUnknown() + val store = ConcordStore(dataDir.concordFile) + val targets = if (handle != null) listOf(store.find(handle) ?: return notFound(handle)) else store.load() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val results = mutableListOf>() + for (sc in targets) { + val relays = relaysFor(ctx, sc) + val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + val received = + ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch) + if (received == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch) + continue + } + if (received.newEpoch <= sc.rootEpoch) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch) + continue + } + // Authorize the rotator against the epoch we are LEAVING — the last plane we can fold. + val cp = controlPlaneKeysFor(sc) + ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) + val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + val editions = ConcordActions.controlEditions(controlWraps, cp) + if (editions.isEmpty()) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded") + continue + } + if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.owner), received.rotator)) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator) + continue + } + val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + store.upsert(storedFrom(sc, adopted)) + 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)) + return 0 + } + } + fun notFound(handle: String): Int { Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index a2d364e8ec..a9e8b0d2d8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition @@ -206,6 +207,176 @@ object ConcordModCommands { operator fun component2() = editions } + /** + * `concord refound COMMUNITY --remove USER[,USER…]` — a CORD-06 Refounding: the hard removal. + * + * A ban only strips standing; the removed member keeps every key they ever held, so the room is + * only truly closed to them by rotating the `community_root` (and, since CORD-02 §2, a fresh + * `control_root` beside it, so a demoted staffer's retained secret dies with the epoch). The + * compacted Control Plane is re-sealed at the new epoch and each retained member gets a rekey + * blob; nobody else can follow. + * + * Authority mirrors Amethyst exactly: `hasPermission`, never `effectivePermissions`, so a banned + * BAN-holder cannot launch one; the owner is never a valid target; and removal takes the same + * rank rule as a ban (CORD-04 §3) — an admin cannot Refound a peer admin out. + * + * **The recipient set is a floor, not a census.** It is the roster ∪ Guestbook ∪ the authors of + * every channel message we can decrypt ∪ ourselves, minus the removed and already-banned — the + * same union Amethyst builds, because a member who only ever posted holds no role and leaves no + * Guestbook motion, and omitting them silently expels them. A member with no trace at all still + * cannot be re-keyed; `concord recover` is how they get back. + */ + suspend fun refound( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val removeArg = args.flag("remove") ?: return Output.error("bad_args", "refound --remove USER[,USER…]").let { 2 } + args.rejectUnknown() + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val removed = + removeArg + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { ctx.requireUserHex(it).lowercase() } + .toSet() + if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user") + + val loaded = load(ctx, sc, dataDir) + val (cp, editions) = loaded + val state = ConcordCommunityState.fold(editions, sc.owner) + val authority = state.authority + val me = ctx.signer.pubKey + + if (!ConcordReceive.isAuthorizedRotator(authority, me)) { + return Output.error("forbidden", "this account cannot refound: a Refounding takes BAN (or ownership), and a banned holder is refused (CORD-06)") + } + if (removed.any { authority.isOwner(it) }) { + return Output.error("forbidden", "the owner is never a valid removal target (CORD-04 §3)") + } + // An admin cannot Refound a peer admin out any more than they could ban one. + if (!authority.isOwner(me) && removed.any { !authority.canActOn(me, it, ConcordPermissions.BAN) }) { + return Output.error("forbidden", "you do not outrank every member you are removing (CORD-04 §3, equal cannot act on equal)") + } + // A Refounding writes the current plane (the pre-rotation bans) and the new one, so on a + // split epoch it takes the current control_root (CORD-02 §2). + writeGuard(cp)?.let { return it } + + val relays = ConcordCommands.relaysFor(ctx, sc) + + // 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 + 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) + 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 = + (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() + + // 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 } + val build = + ConcordActions.buildRefounding( + rotatorSigner = ctx.signer, + communityId = sc.communityId, + priorRoot = sc.root.hexToByteArray(), + newRoot = newRoot, + newControlRoot = newControlRoot, + rootEpoch = sc.rootEpoch, + priorControlWraps = controlWraps, + priorControlKeys = cp, + recipientsXOnly = recipients, + staffXOnly = authority.staffMembers(), + createdAt = TimeUtils.now(), + ownerPubKey = sc.owner, + ) + + // 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it). + build.controlWraps.forEach { ctx.publish(it, relays) } + build.rekeyWraps.forEach { ctx.publish(it, relays) } + + // 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the + // epoch we are leaving for the anti-rollback floor. + val adopted = + ConcordReceive.withAdoptedRoot( + ConcordCommands.entryFor(loaded.community), + newRoot, + build.newEpoch, + build.newControlKeys.address.hexToByteArray(), + newControlRoot, + ) + ConcordStore(dataDir.concordFile).upsert(ConcordCommands.storedFrom(loaded.community, adopted)) + + Output.emit( + mapOf( + "community_id" to sc.communityId, + "removed" to removed.toList(), + "from_epoch" to sc.rootEpoch, + "root_epoch" to build.newEpoch, + "recipients" to recipients.size, + "control_wraps" to build.controlWraps.size, + "rekey_wraps" to build.rekeyWraps.size, + ), + ) + return 0 + } + } + + /** 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 = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() } + + /** Live Guestbook membership at this epoch (joins minus later leaves, CORD-02 §5). */ + private suspend fun guestbookMembersOf( + ctx: Context, + sc: StoredCommunity, + ): Set = + runCatching { + val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + val relays = ConcordCommands.relaysFor(ctx, sc) + ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(gb.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + ConcordActions.guestbookMembers(wraps, gb).mapTo(HashSet()) { it.lowercase() } + }.getOrDefault(emptySet()) + + /** + * Authors of every channel message we can decrypt. Most members never send a Guestbook motion, + * so without this a Refounding silently expels everyone who had only ever posted. + */ + private suspend fun channelAuthorsOf( + ctx: Context, + sc: StoredCommunity, + state: ConcordCommunityState, + ): Set { + val out = HashSet() + val relays = ConcordCommands.relaysFor(ctx, sc) + for ((channelIdHex, _) in state.channels) { + runCatching { + val key = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelIdHex.hexToByteArray(), sc.rootEpoch) + ctx.registerConcordStreamKeys(relays, listOf(key.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(key.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + ConcordActions.channelMessages(wraps, key, channelIdHex, sc.rootEpoch).mapTo(out) { it.author.lowercase() } + } + } + return out + } + /** Drain the control plane and return its keys + current editions to chain onto. */ private suspend fun load( ctx: Context, From 409339b375d34666be96a8d215f413efe52decd3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 19:06:39 -0400 Subject: [PATCH 05/12] feat(concord): re-mint invite links on Refounding so stranded recovery fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the liveness half of A2. Recovery's whole premise is that the community keeps re-minting its bundle at the SAME addressable coordinate, so the link a stranded member already holds starts pointing at the new epoch. Nothing did: `ConcordInviteBundle.mintLink` generates a fresh KeyPair and token per call, and no client persisted `linkSignerPrivKey`. Every mint was a new coordinate, so `recover` could only ever return `already_current` — the mechanism was dead code, and an owner evicted by a rogue admin had no way back. The kind-33301 bundle is addressable and authored by the link signer, so re-signing at that coordinate with the same token replaces what is there and every holder of that link keeps working. Exposes that as `ConcordActions.remintBundleAt`, persists the link signer + token in amy's store at mint time, and has `concord refound` refresh every link it minted for the new epoch. Re-minting every live link is safe precisely because the security half is already in: `refound` bans the removed members on the way out, and `recover` reads the banlist of the epoch being LEFT, so a removed member's own recovery is refused even though their link now resolves. That gate stops being belt-and-braces here and becomes load-bearing — which is what the audit predicted for any client that re-mints (Armada does). Verified end to end against a loopback geode, both directions: bob joins by link, holds no role, never posts (unfindable by a rotation) alice refound --remove → recipients=1, invites_refreshed=1 bob rekey → no_blob_for_us (genuinely stranded) bob recover → recovered, epoch 0 → 1 ← first time this has ever fired bob reads the community at the new epoch alice refound --remove bob → epoch 1 → 2, invites_refreshed=1 bob recover → refused, reason "banned", still at epoch 1 Still open for the shipping client: Amethyst persists no link signer, so A2 liveness remains open on Android. Doing it there means deciding where the secret lives in the kind-13302 list, which Armada also reads — a wire-schema call, not a code one. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/cli/commands/ConcordCommands.kt | 15 +++++++ .../cli/commands/ConcordModCommands.kt | 40 ++++++++++++++++++- .../amethyst/cli/stores/ConcordStore.kt | 16 ++++++++ .../commons/actions/ConcordActions.kt | 23 +++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 1e299b4191..3e06aa5b72 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot +import com.vitorpamplona.amethyst.cli.stores.StoredMintedInvite import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -265,6 +266,20 @@ object ConcordCommands { val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } + // Keep the link signer + token so a later Refounding can refresh THIS coordinate rather + // than orphaning the link at a dead epoch — the liveness half of stranded recovery (A2). + ConcordStore(dataDir.concordFile).upsert( + sc.copy( + mintedInvites = + sc.mintedInvites + + StoredMintedInvite( + linkSignerPrivKey = minted.linkSignerPrivKey.toHexKey(), + token = minted.token.toHexKey(), + createdAt = TimeUtils.now(), + ), + ), + ) + Output.emit( mapOf( "url" to minted.url, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index a9e8b0d2d8..342ae2501d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -322,7 +322,44 @@ object ConcordModCommands { build.newControlKeys.address.hexToByteArray(), newControlRoot, ) - ConcordStore(dataDir.concordFile).upsert(ConcordCommands.storedFrom(loaded.community, adopted)) + val stored = ConcordCommands.storedFrom(loaded.community, adopted) + ConcordStore(dataDir.concordFile).upsert(stored) + + // 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new + // epoch. This is the liveness half of stranded recovery (A2): a member this Refounding + // left out has no rekey blob and no message to miss, so re-resolving their link is the + // only way back — and it only works if the bundle moves with the community instead of + // being orphaned at a dead epoch. Minting a fresh link would not help them; the link + // they hold is the one that must move. + // + // 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 }, + ) + var refreshed = 0 + for (link in stored.mintedInvites) { + runCatching { + val event = + ConcordActions.remintBundleAt( + linkSignerPrivKey = link.linkSignerPrivKey.hexToByteArray(), + token = link.token.hexToByteArray(), + invite = refreshedInvite, + createdAt = TimeUtils.now(), + ) + ctx.publish(event, relays) + refreshed++ + } + } Output.emit( mapOf( @@ -333,6 +370,7 @@ object ConcordModCommands { "recipients" to recipients.size, "control_wraps" to build.controlWraps.size, "rekey_wraps" to build.rekeyWraps.size, + "invites_refreshed" to refreshed, ), ) return 0 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index d2de85a258..e08bfcc8f8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -53,6 +53,22 @@ data class StoredCommunity( // rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct // invite or a community joined before amy stored it. val inviteRef: String = "", + // Invite links WE minted for this community, kept so a Refounding can re-publish each bundle at + // its own coordinate for the new epoch. Without this the link a member joined through points at + // a dead epoch forever and stranded recovery can never fire (A2). Holds link-signer secrets, so + // it sits beside `root`/`controlRoot` in the same already-secret file. + val mintedInvites: List = emptyList(), +) + +/** + * One invite link this account minted: enough to re-sign at its addressable coordinate later. The + * coordinate is the link signer's pubkey, so keeping the private key is what lets a Refounding + * refresh the link (and, in future, revoke it) instead of orphaning it. + */ +data class StoredMintedInvite( + val linkSignerPrivKey: String = "", + val token: String = "", + val createdAt: Long = 0, ) /** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index e70fe18364..6263c65add 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -436,6 +436,29 @@ object ConcordActions { relays: List? = null, ): MintedInviteLink = ConcordInviteBundle.mintLink(base, invite, createdAt, relays) + /** + * Re-publishes a bundle at an **existing** link's coordinate, carrying [invite] refreshed for the + * current epoch (CORD-05 §1). The kind-33301 bundle is addressable and authored by the link + * signer, so re-signing with the same [linkSignerPrivKey] and re-encrypting under the same + * [token] replaces what is there — every holder of that link keeps working, now pointing at the + * new root. + * + * This is what makes stranded recovery live: a member a Refounding left out has no rekey blob and + * no message to miss, and re-resolving their link is the only way back — which requires the + * community to re-mint at the *same* coordinate rather than issuing a fresh link. Minting a new + * link leaves the old one pointing at a dead epoch forever. + * + * Safe to call for every live link because recovery is ban-gated at the epoch being left + * (CORD-06, A2): a member the Refounding removed was banned on the way out, so their own + * `recover` is refused even though their link now resolves. + */ + fun remintBundleAt( + linkSignerPrivKey: ByteArray, + token: ByteArray, + invite: CommunityInvite, + createdAt: Long, + ): Event = ConcordInviteBundle.build(linkSignerPrivKey, token, invite, createdAt) + /** Parses a shareable invite URL into its pointer + private fragment. */ fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url) From 293ffd0bc57daa134cea5ddac1efe0c788a62b45 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 19:50:56 -0400 Subject: [PATCH 06/12] feat(concord): implement the CORD-05 Invite List (kind 13303), wire-compatible with Armada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit kept link secrets in amy's local store, which made link refresh work but only for that one client. The spec already defines where they belong, and Armada implements it, so this replaces the local field with the real cross-client document. Kind 13303, replaceable, NIP-44-encrypted to self — the creator's private bookkeeping: { "entries": [ { "token", "signer_sk", "community_id", "url", "label?", "created_at", "expires_at?" } ], "tombstones": [ { "token", "community_id" } ] } `token` is both the link's unlock secret and the merge key; `signer_sk` is what lets any of the creator's clients re-sign at that link's addressable coordinate. Armada types both the entry and the tombstone as `[k: string]: unknown`, so unknown keys are contract: the codec preserves entry-, tombstone- and document-level residue, and re-encoding never deletes another client's data. Merge is by token, read-merge-write rather than overwrite — the list is replaceable and per-creator, so two devices minting concurrently would otherwise destroy each other's `signer_sk`, which is unrecoverable. A token tombstoned on either side stays dropped, so a stale device cannot resurrect a retired link. Registers 13303 in EventFactory (without it the kind deserializes as a plain Event and every typed read fails), and points amy's mint and Refounding refresh at the list instead of its own store. Semantics were taken from the spec and confirmed against Armada's observable behaviour — read for semantics only, never copied: Armada is AGPLv3 and Amethyst is MIT. Verified against a loopback geode: `concord invite` publishes an encrypted, untagged 13303; a Refounding reads it back, refreshes the live links, and a member with no role who never posted — unfindable by any rotation — recovers epoch 0 → 1 through the link he already held. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/cli/commands/ConcordCommands.kt | 71 +++++- .../cli/commands/ConcordModCommands.kt | 11 +- .../amethyst/cli/stores/ConcordStore.kt | 16 -- .../cord05Invites/ConcordInviteList.kt | 209 ++++++++++++++++++ .../cord05Invites/ConcordInviteListEvent.kt | 80 +++++++ .../quartz/utils/EventFactory.kt | 2 + .../cord05Invites/ConcordInviteListTest.kt | 133 +++++++++++ 7 files changed, 491 insertions(+), 31 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 3e06aa5b72..76e7a8d59b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot -import com.vitorpamplona.amethyst.cli.stores.StoredMintedInvite import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -35,6 +34,10 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -266,18 +269,25 @@ object ConcordCommands { val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } - // Keep the link signer + token so a later Refounding can refresh THIS coordinate rather - // than orphaning the link at a dead epoch — the liveness half of stranded recovery (A2). - ConcordStore(dataDir.concordFile).upsert( - sc.copy( - mintedInvites = - sc.mintedInvites + - StoredMintedInvite( - linkSignerPrivKey = minted.linkSignerPrivKey.toHexKey(), - token = minted.token.toHexKey(), - createdAt = TimeUtils.now(), + // 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 = + ConcordInviteListDocument( + entries = + listOf( + ConcordInviteListEntry( + token = minted.token.toHexKey(), + signerSk = minted.linkSignerPrivKey.toHexKey(), + communityId = sc.communityId, + url = minted.url, + createdAt = TimeUtils.now(), + ), ), - ), + ), ) Output.emit( @@ -570,6 +580,43 @@ object ConcordCommands { } } + /** + * This account's CORD-05 Invite List (kind 13303) — the creator's private, self-encrypted record + * 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 = emptySet(), + ): ConcordInviteListDocument { + val relays = ctx.outboxRelays() + extraRelays + if (relays.isEmpty()) return ConcordInviteListDocument.EMPTY + 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 + } + + /** + * 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). + */ + suspend fun publishInviteList( + ctx: Context, + patch: ConcordInviteListDocument, + extraRelays: Set = 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) + } + fun notFound(handle: String): Int { Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 342ae2501d..23ec2a47ef 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -346,15 +346,20 @@ object ConcordModCommands { stored.relays, stored.controlPk.ifBlank { null }, ) + val now = TimeUtils.now() var refreshed = 0 - for (link in stored.mintedInvites) { + for (link in ConcordCommands.readInviteList(ctx, relays).entries) { + 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 runCatching { val event = ConcordActions.remintBundleAt( - linkSignerPrivKey = link.linkSignerPrivKey.hexToByteArray(), + linkSignerPrivKey = link.signerSk.hexToByteArray(), token = link.token.hexToByteArray(), invite = refreshedInvite, - createdAt = TimeUtils.now(), + createdAt = now, ) ctx.publish(event, relays) refreshed++ diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index e08bfcc8f8..d2de85a258 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -53,22 +53,6 @@ data class StoredCommunity( // rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct // invite or a community joined before amy stored it. val inviteRef: String = "", - // Invite links WE minted for this community, kept so a Refounding can re-publish each bundle at - // its own coordinate for the new epoch. Without this the link a member joined through points at - // a dead epoch forever and stranded recovery can never fire (A2). Holds link-signer secrets, so - // it sits beside `root`/`controlRoot` in the same already-secret file. - val mintedInvites: List = emptyList(), -) - -/** - * One invite link this account minted: enough to re-sign at its addressable coordinate later. The - * coordinate is the link signer's pubkey, so keeping the private key is what lets a Refounding - * refresh the link (and, in future, revoke it) instead of orphaning it. - */ -data class StoredMintedInvite( - val linkSignerPrivKey: String = "", - val token: String = "", - val createdAt: Long = 0, ) /** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt new file mode 100644 index 0000000000..3eea2095c3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt @@ -0,0 +1,209 @@ +/* + * 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.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.jsonObject + +private val NoExtras: JsonObject = JsonObject(emptyMap()) + +/** + * One minted invite link, as the creator's own bookkeeping (CORD-05, kind 13303). + * + * [token] is both the link's unlock secret and the **merge key** across devices, and [signerSk] is + * the link signer's private key — which is what makes a link *refreshable*. The kind-33301 bundle is + * addressable and authored by that signer, so re-posting under it moves the link to the current + * epoch without changing the URL anyone already holds. Lose the secret and the link is orphaned at a + * dead epoch forever, which is what made stranded recovery unreachable in practice. + * + * [residue] carries wire keys this build does not model. Armada types both the entry and the + * tombstone as `[k: string]: unknown`, so unknown keys are part of the contract: dropping them on a + * re-encode deletes another client's data. + */ +class ConcordInviteListEntry( + val token: String, + val signerSk: String, + val communityId: String, + val url: String, + val label: String? = null, + val createdAt: Long = 0, + val expiresAt: Long? = null, + val residue: JsonObject = NoExtras, +) { + /** 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 +} + +/** A retired link: the creator's record that [token] is gone, kept so a merge cannot resurrect it. */ +class ConcordInviteListTombstone( + val token: String, + val communityId: String, + val residue: JsonObject = NoExtras, +) + +/** The decoded kind-13303 document: live [entries], [tombstones], and document-level [residue]. */ +class ConcordInviteListDocument( + val entries: List = emptyList(), + val tombstones: List = emptyList(), + val residue: JsonObject = NoExtras, +) { + companion object { + val EMPTY = ConcordInviteListDocument() + } +} + +/** + * Codec + merge for the CORD-05 Invite List (kind 13303), the creator's private, NIP-44 self- + * encrypted bookkeeping of the links they minted. Wire-compatible with Armada's `invite.ts`: + * + * ```jsonc + * { "entries": [ { "token", "signer_sk", "community_id", "url", "label?", "created_at", "expires_at?" } ], + * "tombstones": [ { "token", "community_id" } ] } + * ``` + */ +object ConcordInviteList { + private const val EXTRAS = "__extras" + + /** Wraps a generated serializer so unknown keys survive a decode → modify → encode. */ + private open class ExtrasPreserving( + delegate: KSerializer, + ) : JsonTransformingSerializer(delegate) { + @OptIn(ExperimentalSerializationApi::class) + private val known = delegate.descriptor.elementNames.toSet() - EXTRAS + + override fun transformDeserialize(element: JsonElement): JsonElement { + val obj = element as? JsonObject ?: return element + val extras = obj.filterKeys { it !in known } + if (extras.isEmpty()) return obj + return JsonObject(obj.filterKeys { it in known } + (EXTRAS to JsonObject(extras))) + } + + override fun transformSerialize(element: JsonElement): JsonElement { + val obj = element as? JsonObject ?: return element + val extras = obj[EXTRAS]?.jsonObject ?: return obj + return JsonObject(extras + (obj - EXTRAS)) + } + } + + @Serializable + private class WireEntry( + val token: String = "", + @SerialName("signer_sk") val signerSk: String = "", + @SerialName("community_id") val communityId: String = "", + val url: String = "", + val label: String? = null, + @SerialName("created_at") val createdAt: Long = 0, + @SerialName("expires_at") val expiresAt: Long? = null, + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, + ) + + @Serializable + private class WireTombstone( + val token: String = "", + @SerialName("community_id") val communityId: String = "", + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, + ) + + private object WireEntrySerializer : ExtrasPreserving(WireEntry.serializer()) + + private object WireTombstoneSerializer : ExtrasPreserving(WireTombstone.serializer()) + + @Serializable + private class WireDocument( + val entries: List< + @Serializable(WireEntrySerializer::class) + WireEntry, + > = emptyList(), + val tombstones: List< + @Serializable(WireTombstoneSerializer::class) + WireTombstone, + > = emptyList(), + @SerialName(EXTRAS) val extras: JsonObject = NoExtras, + ) + + private object WireDocumentSerializer : ExtrasPreserving(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. + */ + fun decode(json: String): ConcordInviteListDocument = + try { + val doc = ConcordJson.instance.decodeFromString(WireDocumentSerializer, json) + ConcordInviteListDocument( + entries = + doc.entries.map { + ConcordInviteListEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.extras) + }, + tombstones = doc.tombstones.map { ConcordInviteListTombstone(it.token, it.communityId, it.extras) }, + residue = doc.extras, + ) + } catch (_: Exception) { + ConcordInviteListDocument.EMPTY + } + + fun encode(doc: ConcordInviteListDocument): String = + ConcordJson.instance.encodeToString( + WireDocumentSerializer, + WireDocument( + entries = + doc.entries.map { + WireEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.residue) + }, + tombstones = doc.tombstones.map { WireTombstone(it.token, it.communityId, it.residue) }, + extras = doc.residue, + ), + ) + + /** + * Merges [patch] onto [base], keyed by `token` — the spec's own merge key. A token present in + * either side's tombstones is dropped from the result and kept tombstoned, so a retired link + * cannot be resurrected by a device that still has it cached. [patch] wins field-by-field on a + * token both sides carry, which is what makes "read remote, apply my change, publish" converge. + */ + fun merge( + base: ConcordInviteListDocument, + patch: ConcordInviteListDocument, + ): ConcordInviteListDocument { + val tombstones = LinkedHashMap() + for (t in base.tombstones + patch.tombstones) tombstones[t.token] = t + + val entries = LinkedHashMap() + for (e in base.entries + patch.entries) { + if (e.token in tombstones) continue + entries[e.token] = e + } + return ConcordInviteListDocument( + entries = entries.values.toList(), + tombstones = tombstones.values.toList(), + residue = JsonObject(base.residue + patch.residue), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt new file mode 100644 index 0000000000..f4bbd80f1c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt @@ -0,0 +1,80 @@ +/* + * 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.cord05Invites + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The CORD-05 **Invite List** (kind 13303): the creator's private, NIP-44 self-encrypted record of + * every link they minted — `token` (the unlock secret and merge key) and `signer_sk` (the link + * signer's private key) per entry. + * + * It exists so a link can be *refreshed*: the kind-33301 bundle is addressable and authored by the + * link signer, so re-posting under it moves the link to the current epoch behind the same URL (e.g. + * after a Rekey). Without the list a client cannot re-sign at that coordinate, every rotation + * orphans every outstanding link, and stranded recovery — whose whole premise is re-resolving the + * link you joined through — can never fire. + * + * Replaceable and per-creator: the coordinate is (kind, creator pubkey, ""), so a creator's devices + * converge on one list. Merge by `token` ([ConcordInviteList.merge]) rather than overwriting, or two + * devices minting concurrently lose each other's links. + */ +@Immutable +class ConcordInviteListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + 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. + */ + suspend fun decrypt(signer: NostrSigner): ConcordInviteListDocument = + try { + ConcordInviteList.decode(signer.nip44Decrypt(content, signer.pubKey)) + } catch (_: Exception) { + ConcordInviteListDocument.EMPTY + } + + companion object { + const val KIND = 13303 + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, "") + + suspend fun create( + signer: NostrSigner, + document: ConcordInviteListDocument, + createdAt: Long = TimeUtils.now(), + ): ConcordInviteListEvent { + val content = signer.nip44Encrypt(ConcordInviteList.encode(document), signer.pubKey) + return signer.sign(createdAt, KIND, emptyArray(), content) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 622fa0b889..3955952fdd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -101,6 +101,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent @@ -804,6 +805,7 @@ class EventFactory { RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig) ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig) + ConcordInviteListEvent.KIND -> ConcordInviteListEvent(id, pubKey, createdAt, tags, content, sig) ConcordInviteBundleEvent.KIND -> ConcordInviteBundleEvent(id, pubKey, createdAt, tags, content, sig) SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt new file mode 100644 index 0000000000..e488aeccb5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt @@ -0,0 +1,133 @@ +/* + * 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.cord05Invites + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Wire conformance for the CORD-05 Invite List (kind 13303). The whole point of this document is + * cross-client: a link minted in Armada must be refreshable from Amethyst and back, so the field + * names and the merge key are contract, not preference. + */ +class ConcordInviteListTest { + // The spec's own example document, verbatim in shape. + private val specJson = + """ + { "entries": [ + { "token": "aa11", + "signer_sk": "bb22", + "community_id": "cc33", + "url": "https://vector.chat/invite/naddr1abc#frag", + "label": "Reddit", + "created_at": 1719800000, + "expires_at": 1722400000 } ], + "tombstones": [ { "token": "dd44", "community_id": "cc33" } ] } + """.trimIndent() + + @Test + fun readsTheSpecDocumentIntoTypedEntries() { + val doc = ConcordInviteList.decode(specJson) + + assertEquals(1, doc.entries.size) + val e = doc.entries.first() + assertEquals("aa11", e.token) + assertEquals("bb22", e.signerSk) + assertEquals("cc33", e.communityId) + assertEquals("https://vector.chat/invite/naddr1abc#frag", e.url) + assertEquals("Reddit", e.label) + assertEquals(1719800000L, e.createdAt) + assertEquals(1722400000L, e.expiresAt) + + assertEquals(1, doc.tombstones.size) + assertEquals("dd44", doc.tombstones.first().token) + assertEquals("cc33", doc.tombstones.first().communityId) + } + + @Test + fun emitsTheSnakeCaseKeysAnotherClientReads() { + val json = ConcordInviteList.encode(ConcordInviteList.decode(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") + } + } + + @Test + fun keepsUnknownKeysAcrossADecodeEncodeCycle() { + // Armada types the entry and tombstone as `[k: string]: unknown`, so dropping a key we do + // not model deletes another client's data on our next publish. + val withExtras = + """ + { "entries": [ { "token": "aa11", "signer_sk": "bb22", "community_id": "cc33", + "url": "u", "created_at": 1, "future_field": {"a":1} } ], + "tombstones": [ { "token": "dd44", "community_id": "cc33", "why": "revoked" } ], + "doc_level_unknown": 7 } + """.trimIndent() + + val round = ConcordInviteList.encode(ConcordInviteList.decode(withExtras)) + + assertTrue(round.contains("future_field"), "entry-level unknown key dropped") + assertTrue(round.contains("doc_level_unknown"), "document-level unknown key dropped") + assertTrue(round.contains("\"why\""), "tombstone unknown key dropped") + } + + @Test + fun mergesByTokenAndLetsTombstonesWin() { + val base = + ConcordInviteListDocument( + entries = + listOf( + ConcordInviteListEntry("t1", "sk1", "c", "url1", createdAt = 1), + ConcordInviteListEntry("t2", "sk2", "c", "url2", createdAt = 2), + ), + ) + // Another device minted t3 and retired t1. + val patch = + ConcordInviteListDocument( + entries = listOf(ConcordInviteListEntry("t3", "sk3", "c", "url3", createdAt = 3)), + tombstones = listOf(ConcordInviteListTombstone("t1", "c")), + ) + + val merged = ConcordInviteList.merge(base, patch) + val tokens = merged.entries.map { it.token }.toSet() + + assertEquals(setOf("t2", "t3"), tokens, "merge is keyed by token; a tombstoned link is dropped") + assertTrue(merged.tombstones.any { it.token == "t1" }, "the tombstone must persist or a stale device resurrects the link") + } + + @Test + fun aMalformedDocumentYieldsEmptyRatherThanThrowing() { + assertEquals(0, ConcordInviteList.decode("not json").entries.size) + assertEquals(0, ConcordInviteList.decode("{\"entries\":\"wrong type\"}").entries.size) + } + + @Test + fun anExpiredLinkIsNotRefreshable() { + val live = ConcordInviteListEntry("t", "sk", "c", "u", expiresAt = 100) + val forever = ConcordInviteListEntry("t", "sk", "c", "u", expiresAt = null) + + assertTrue(live.isExpired(nowSecs = 101), "an elapsed link can no longer be joined") + assertTrue(!live.isExpired(nowSecs = 99)) + assertTrue(!forever.isExpired(nowSecs = Long.MAX_VALUE), "no expiry means it never elapses") + } +} From 66d27772623c258911edada18760896d9a186875 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 21:48:19 -0400 Subject: [PATCH 07/12] feat(concord): wire the Invite List into Amethyst; fix the QR quiet zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android half of the CORD-05 Invite List (kind 13303). Minting records the link's `token` + `signer_sk` in the shared, self-encrypted list, and a Refounding re-posts every live link it finds there at that link's own coordinate, carrying the new epoch. Read-merge-write, never overwrite: two of the user's devices minting concurrently would otherwise delete each other's `signer_sk`, which is unrecoverable. Expired links are skipped — re-posting one would only resurrect a dead URL at a live epoch. Verified on a Galaxy Tab A7 Lite against a loopback geode, driving the real UI: - tapping Invite publishes a kind-13303 authored by the device - Remove member → the Refounding publishes 5 control wraps + 1 rekey blob; `amy` follows it (epoch 0 → 1), and the removed member gets `no_blob_for_us` and stays behind - with a device-minted link in the list, the next Refounding logs "refreshed 1 invite link(s) to epoch 2" and the bundle at that link's coordinate is REPLACED in place rather than orphaned Also fixes the invite QR rendering as a postage stamp. QrCodeDrawer's quiet zone was a fixed 100px per side, which does not scale: at the dialog's 220dp box that ate ~45% of the canvas, so a long payload drew tiny inside a large white card. Expressed as the QR spec's 4-module zone it stays proportional, and the code now fills whatever box it is given at every call site. Note: OpenCV cannot decode this drawer's stylized modules either before or after the change, so scannability was not machine-verified — the change only shrinks excess quiet zone to the spec minimum and enlarges the modules, but a camera check before release is worthwhile. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/model/AccountConcordActions.kt | 107 ++++++++++++++++++ .../ui/note/share/ShareNoteAsQrScreen.kt | 2 +- .../ui/screen/loggedIn/qrcode/QrCodeDrawer.kt | 32 ++++-- 3 files changed, 131 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index 4caf607709..543206fc94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -40,6 +40,10 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys @@ -164,6 +168,83 @@ class AccountConcordActions( return community.communityIdHex } + // ---- 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. + * + * 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): ConcordInviteListDocument { + if (relays.isEmpty()) return ConcordInviteListDocument.EMPTY + 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 + } + + /** + * 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. + */ + private suspend fun publishConcordInviteList( + patch: ConcordInviteListDocument, + relays: Set, + ) { + 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) + } + + /** + * 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 + * 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. + */ + 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 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) { + 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 + runCatching { + account.client.publish( + ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), link.token.hexToByteArray(), refreshed, now), + relays, + ) + count++ + }.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) } + } + return count + } + /** * Mint a shareable invite link for a joined community and publish its * kind-33301 public bundle to the community relays. Returns the `…/invite/…` @@ -209,6 +290,24 @@ class AccountConcordActions( 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(), + ), + ), + ), + publishTo, + ) return minted.url } @@ -862,6 +961,14 @@ 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) + + // 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 + Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" } return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt index 5bbf7bfdfa..03c78556de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt @@ -61,7 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes -// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a +// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_QUIET_ZONE_MODULES in QrCodeDrawer.kt) is a // fixed pixel count subtracted from raw size.width, so its share of the tile grows as density // falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable // resolution on low-density screens. Deriving the size from the available column width keeps diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt index d4e624d926..a31a83b21c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/QrCodeDrawer.kt @@ -49,7 +49,15 @@ import com.google.zxing.qrcode.encoder.Encoder import com.google.zxing.qrcode.encoder.QRCode import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -const val QR_MARGIN_PX = 100f +/** + * The quiet zone around the code, in **modules** — the QR spec's minimum of 4. + * + * It was a fixed 100px per side, which does not scale: at a small draw size those 200px ate most of + * the canvas, so a long payload (a Concord invite link, an nprofile) rendered as a postage stamp + * floating in white. Expressed in modules the zone stays proportional, so the code fills whatever + * box it is given at every size while remaining scannable. + */ +const val QR_QUIET_ZONE_MODULES = 4f @Preview @Composable @@ -78,13 +86,16 @@ fun QrCodeDrawer( ) { Canvas(modifier = Modifier.fillMaxSize()) { // Calculate the height and width of each column/row - val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height - val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width + // Solve for the module size with the quiet zone measured in modules, so the whole code + // (zone included) is exactly as wide as the canvas. + val rowHeight = size.height / (qrCode.matrix.height + QR_QUIET_ZONE_MODULES * 2f) + val columnWidth = size.width / (qrCode.matrix.width + QR_QUIET_ZONE_MODULES * 2f) val radius = CornerRadius(20f) // Draw all of the finder patterns required by the QR spec. Calculate the ratio // of the number of rows/columns to the width and height drawQrCodeFinders( + quietZonePx = columnWidth * QR_QUIET_ZONE_MODULES, sideLength = size.width, finderPatternSize = Size( @@ -97,6 +108,7 @@ fun QrCodeDrawer( // Draw data bits (encoded data part) drawAllQrCodeDataBits( + quietZonePx = columnWidth * QR_QUIET_ZONE_MODULES, bytes = qrCode.matrix, size = Size( @@ -119,7 +131,7 @@ private fun createQrCode(contents: String): QRCode { ErrorCorrectionLevel.Q, mapOf( EncodeHintType.CHARACTER_SET to "UTF-8", - EncodeHintType.MARGIN to QR_MARGIN_PX, + EncodeHintType.MARGIN to QR_QUIET_ZONE_MODULES, EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q, ), ) @@ -132,6 +144,7 @@ fun newPath(withPath: Path.() -> Unit) = } fun DrawScope.drawAllQrCodeDataBits( + quietZonePx: Float, bytes: ByteMatrix, size: Size, color: Color, @@ -182,8 +195,8 @@ fun DrawScope.drawAllQrCodeDataBits( Rect( offset = Offset( - x = QR_MARGIN_PX + x * size.width, - y = QR_MARGIN_PX + y * size.height, + x = quietZonePx + x * size.width, + y = quietZonePx + y * size.height, ), size = newSize, ), @@ -212,6 +225,7 @@ private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f * @param finderPatternSize [Size] of each finder patten, based on the QR code spec */ internal fun DrawScope.drawQrCodeFinders( + quietZonePx: Float, sideLength: Float, finderPatternSize: Size, cornerRadius: CornerRadius, @@ -219,11 +233,11 @@ internal fun DrawScope.drawQrCodeFinders( ) { setOf( // Draw top left finder pattern. - Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX), + Offset(x = quietZonePx, y = quietZonePx), // Draw top right finder pattern. - Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX), + Offset(x = sideLength - (quietZonePx + finderPatternSize.width), y = quietZonePx), // Draw bottom finder pattern. - Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height)), + Offset(x = quietZonePx, y = sideLength - (quietZonePx + finderPatternSize.height)), ).forEach { offset -> drawQrCodeFinder( topLeft = offset, From eb8c812690ebb5cc528df593415040aac7d7943e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 23:39:54 -0400 Subject: [PATCH 08/12] fix(concord): close ten review findings in the CORD-05 invite path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../amethyst/model/AccountConcordActions.kt | 186 +++++++++++------- .../amethyst/model/ConcordInviteResult.kt | 9 + .../concord/ConcordInviteScreen.kt | 2 + amethyst/src/main/res/values/strings.xml | 1 + .../amethyst/cli/commands/ConcordCommands.kt | 99 +++++++--- .../cli/commands/ConcordModCommands.kt | 96 ++++++--- .../amethyst/cli/stores/ConcordStore.kt | 6 + .../concord/cord05Invites/CommunityInvite.kt | 2 +- .../cord05Invites/ConcordInviteList.kt | 24 ++- .../cord05Invites/ConcordInviteListEvent.kt | 16 +- .../cord05Invites/ConcordInviteListTest.kt | 39 +++- 11 files changed, 339 insertions(+), 141 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index 543206fc94..daad3aa9c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -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): 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, - ) { - 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 } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt index 6b4d2599cb..8f502e358b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index 2788dee7ee..d56c800791 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -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 -> diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f3e96dffdf..20e32ea0b7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -321,6 +321,7 @@ This invite link is invalid or can\'t be opened with this account. 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. This invite link has been revoked and can no longer be used. Ask for a new one. + This community has removed you. The link still works, but its member list does not admit you. This invite link has expired and can no longer be used. Ask for a fresh link. Community name is only revealed after you join 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. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 76e7a8d59b..f2890e7775 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -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 = 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 = 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 { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 23ec2a47ef..9b52786df6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -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() 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() + 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, + authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver, + ): List { + if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList() + val vouched = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() } + val kept = LinkedHashSet() + 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 = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index d2de85a258..c36b1ac915 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -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 = "", ) /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt index d652c70143..632a7f9e03 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt index 3eea2095c3..5b930eb261 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt @@ -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.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 = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt index f4bbd80f1c..3816f60a6f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListEvent.kt @@ -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 { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt index e488aeccb5..7a3904bdbc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt @@ -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("")) + + 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 From a1f980babd3d19f2447f12af521e422fab16e2e2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 23:40:30 -0400 Subject: [PATCH 09/12] fix(concord): make the invite failure messages visible in the dark theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while proving the new ban gate on a tablet: the refusal reached the view hierarchy but rendered as black pixels on the black background, so the screen looked blank and the user was told nothing. `ConcordInviteScreen`'s Column sits on the bare window background with no Surface above it, so `LocalContentColor` is still Material 3's default black. This predates the invite work and silently affects every state the screen can end in — invalid, incompatible, revoked, expired, unreachable — plus the "Redeeming invite…" progress label, which is why only the spinner was ever visible while a join was in flight. Verified on device: the message now renders. Co-Authored-By: Claude Opus 5 (1M context) --- .../chats/publicChannels/concord/ConcordInviteScreen.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index d56c800791..7b19436713 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -163,6 +163,10 @@ fun ConcordInviteScreen( Text( stringRes(R.string.concord_redeeming_invite), modifier = Modifier.padding(top = 16.dp), + // Explicit: this Column sits on the bare window background with no Surface + // above it, so LocalContentColor is still the M3 default black — which renders + // every one of these labels invisible in the dark theme. + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) } @@ -172,6 +176,7 @@ fun ConcordInviteScreen( Text( stringRes(failed.messageRes), style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) if (failed.canRetry) { From fc3f181184f4a034db604762fa7921f62d03d079 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Aug 2026 00:30:09 -0400 Subject: [PATCH 10/12] =?UTF-8?q?feat(cli):=20add=20`amy=20concord=20revok?= =?UTF-8?q?e`=20=E2=80=94=20retire=20an=20invite=20link=20(CORD-05)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reading half of revocation already existed: `classify` has always resolved a `vsk=9` tombstone to `Revoked`, and Amethyst's join honours it. Nothing anywhere could *produce* one, so a leaked link could only be outrun by a Refounding — rotating the whole community to retire one URL. `ConcordInviteBundle.buildRevocation` emits the grave the spec describes and Armada's `buildRevocationEvent` already publishes: kind 33301 at the link's own `["d",""]` coordinate, empty content, `["vsk","9"]`, signed by the `link_signer` secret. Empty content is the interop contract, not an omission — there is nothing to encrypt when the point is that no bundle key opens anything. `amy concord revoke COMMUNITY TOKEN|URL` takes either the shareable URL a creator actually has to hand or the bare token. It publishes the wire tombstone FIRST and records the kind-13303 tombstone second, which is the inverse of minting and deliberate: the list entry holds the only copy of the `signer_sk` the publish needs, and a merge drops a tombstoned token's entry terminally. Recording first and then failing to publish would leave the link live with its signer gone and no way left to retire it. A failed list write is recoverable by comparison and is reported rather than swallowed. Also fixes a revocation bypass in amy's own `join`, found while testing this: it opened the first wrap that decrypted instead of classifying the coordinate, so a relay still serving a stale copy alongside the grave would have handed out a revoked link. It now resolves per CORD-05 §2 like Amethyst does, and can say which of revoked/expired/unreadable/absent it hit instead of reporting everything as `not_found`. Verified end to end against a local relay: revoking flips the coordinate to vsk=9 with empty content, the link is refused from that moment on, a second revoke reports `already_revoked`, and a Refounding afterwards moves the surviving link while leaving the grave alone — the first real proof of the tombstone-skip in the refresh path, which until now had only unit coverage. Co-Authored-By: Claude Opus 5 (1M context) --- cli/README.md | 1 + cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 1 + .../amethyst/cli/commands/ConcordCommands.kt | 107 +++++++++++++++++- .../commons/actions/ConcordActions.kt | 15 +++ .../cord05Invites/ConcordInviteBundle.kt | 16 +++ .../bundle/ConcordInviteBundleEvent.kt | 17 +++ .../ConcordInviteClassifyTest.kt | 35 ++++++ 8 files changed, 190 insertions(+), 4 deletions(-) diff --git a/cli/README.md b/cli/README.md index d3f0591f78..76ec8f2844 100644 --- a/cli/README.md +++ b/cli/README.md @@ -669,6 +669,7 @@ also carried on-relay as an encrypted kind:13302. | `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). | | `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane. | | `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. | +| `amy concord 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 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`). | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index add1d87d06..590964f6d4 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -67,7 +67,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. | | CLINK offers + debits (`amy offer` / `amy debit`) | ✅ | `OfferCommands` + `DebitCommands` — pointer decode, NIP-05 discover, kind:21001/21002 round-trips, `offer pay --with NDEBIT` end-to-end settlement. `--timeout` is SECONDS. | | Geochat (Bitchat geohash, ephemeral kind:20000) | ✅ | `GeochatCommands` — listen/send/keys with per-geohash throwaway identity + geo-nearest relay routing; doubles as the Bitchat interop harness. | -| Concord Channels (encrypted communities) | ✅ | `ConcordCommands` — 13 sub-verbs (create/list/import/channels/send/read/invite/join/roles/role/grant/ban/unban) over shared `commons` `ConcordActions`; secrets in `concord.json`. | +| Concord Channels (encrypted communities) | ✅ | `ConcordCommands` — 17 sub-verbs (create/list/import/channels/send/read/invite/revoke/join/recover/rekey/roles/role/grant/ban/unban/refound) over shared `commons` `ConcordActions`; secrets in `concord.json`. | | NIP-5A nsites + NIP-5D napplets | ✅ | `NsiteCommands` + `NappletCommands` — fetch/publish/serve/list with sha256 + aggregate-hash verification and `requires` capability reporting. | | Podcasting 2.0 / podstr (`amy podcast20`) | ✅ | `Podcast20Commands` — kind:30078 metadata, 30054 episodes, 30055 trailers, list. | | Follows-of-follows (`amy fof get/list/sync`) | ✅ | `FofCommand` — single-hop social proof from the local store (`wot` kept as deprecation alias). | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 5b14b8da61..0ddc3f5762 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -869,6 +869,7 @@ private fun printUsage() { | concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id) | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone) | concord join URL redeem an invite link and save the community | |Local event store (shared, under `/shared/`): diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index f2890e7775..77047c4e5b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -66,6 +67,9 @@ object ConcordCommands { | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50); | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord revoke COMMUNITY TOKEN|URL retire a link you minted: publishes a vsk=9 + | tombstone at its coordinate, then tombstones + | it in your invite list so it stays retired | concord join URL redeem an invite link and save the community | concord rekey [COMMUNITY] follow a Refounding we were re-keyed for: | open our blob and adopt the new epoch @@ -89,7 +93,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -100,6 +104,7 @@ object ConcordCommands { "send" to { rest -> ConcordChannelCommands.send(dataDir, rest) }, "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, "invite" to { rest -> invite(dataDir, rest) }, + "revoke" to { rest -> revoke(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, "recover" to { rest -> recover(dataDir, rest) }, "rekey" to { rest -> rekey(dataDir, rest) }, @@ -307,6 +312,92 @@ object ConcordCommands { } } + /** + * `amy concord revoke ` — retires one link this account minted. + * + * Two records have to agree for a link to be gone, and they fail differently, so the order is + * deliberate. The wire tombstone (`vsk=9` at the link's own coordinate) is what actually stops + * a join, and publishing it needs the `signer_sk` that only the kind-13303 Invite List holds. + * The list tombstone is bookkeeping: it stops a later Refounding from re-minting the link. + * + * So the wire goes first and the list second. The reverse order would delete the entry — a + * merge drops a tombstoned token's entry terminally — and if the publish then failed, the link + * would stay live with its `signer_sk` gone and no way left to retire it. A failed list write + * is recoverable by comparison: the link is already dead on the wire, and the refresh path + * re-mints only a coordinate that still resolves Live, so it will not resurrect this one. + */ + private suspend fun revoke( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val link = args.positional(1, "token|url") + args.rejectUnknown() + + // Accept either the shareable URL (what a creator actually has to hand) or the bare token. + val token = + ConcordActions + .parseInviteLink(link) + ?.fragment + ?.token + ?.toHexKey() ?: link.lowercase() + if (!TOKEN_HEX.matches(token)) { + return Output.error("bad_args", "expected an invite URL or a 32-hex-character link token, got '$link'").let { 2 } + } + + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle) + Context.open(dataDir).use { ctx -> + ctx.prepare() + + val list = + readInviteList(ctx) + ?: return Output.error("invite_list_unreadable", "could not read your invite list (kind 13303), so the link signer needed to revoke is unknown — refusing to guess") + + val entry = list.entries.firstOrNull { it.token == token } + if (entry == null) { + return if (list.tombstones.any { it.token == token }) { + Output.error("already_revoked", "this link was already revoked; its signer_sk is gone from the list, so there is nothing left to re-publish") + } else { + Output.error("not_found", "no link with token $token in your invite list — only the account that minted a link can revoke it") + } + } + if (entry.communityId != sc.communityId) { + return Output.error("wrong_community", "that link belongs to community ${entry.communityId}, not '$handle' (${sc.communityId})") + } + + val tombstone = ConcordActions.revokeBundleAt(entry.signerSk.hexToByteArray(), TimeUtils.now()) + val ack = ctx.publish(tombstone, relaysFor(ctx, sc)) + RawEventSupport.publishGuard(ack, tombstone.id)?.let { return it } + + val recorded = + publishInviteList( + ctx, + ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = sc.communityId))), + ) + if (!recorded) { + System.err.println( + "[concord] the link is revoked on the wire but the tombstone could not be recorded in your invite list (kind 13303); re-run this command once your outbox relays are reachable", + ) + } + + Output.emit( + mapOf( + "revoked" to true, + "token" to token, + "community_id" to sc.communityId, + "link_signer" to entry.signerPubKeyHex(), + "tombstone_event_id" to tombstone.id, + "tombstoned_in_list" to recorded, + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } + } + + /** A link token is 16 bytes on the wire, so 32 hex characters once stored in the list. */ + private val TOKEN_HEX = Regex("^[0-9a-f]{32}$") + private suspend fun join( dataDir: DataDir, rest: Array, @@ -320,9 +411,19 @@ object ConcordCommands { ctx.prepare() val relays = (normalize(parsed.fragment.relays) + ctx.bootstrapRelays()) val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second } + // Resolve the coordinate per CORD-05 §2 rather than opening whatever happens to decrypt: + // the newest event wins, so a vsk=9 tombstone retires the link even when a stale but + // still-openable copy is also present. Opening the first wrap that decrypts would let a + // relay that kept the old version hand out a link its creator revoked — and it cannot + // tell the user which of "revoked", "expired" or "gone" they are looking at. val bundle = - wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } - ?: return Output.error("not_found", "no valid bundle for this link").let { 1 } + when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { + is InviteBundleStatus.Live -> status.invite + is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined") + InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator") + InviteBundleStatus.Unreadable -> return Output.error("incompatible", "something is published at this link's coordinate, but it is not a bundle this client can open") + InviteBundleStatus.Absent -> return Output.error("not_found", "no bundle for this link on any of its relays") + } // 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 6263c65add..dfb51935d7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -459,6 +459,21 @@ object ConcordActions { createdAt: Long, ): Event = ConcordInviteBundle.build(linkSignerPrivKey, token, invite, createdAt) + /** + * Retires an existing link by publishing a `vsk=9` revocation tombstone at its coordinate + * (CORD-05 §2). Once this lands, every client resolving that URL gets + * [com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus.Revoked] instead of keys. + * + * Publish this *before* recording the tombstone in the kind-13303 Invite List — the list entry + * carries the only copy of the `signer_sk` this call needs, and the list merge drops a + * tombstoned token's entry for good. Recording first and failing to publish would leave the link + * live on the wire with no way left to retire it. + */ + fun revokeBundleAt( + linkSignerPrivKey: ByteArray, + createdAt: Long, + ): Event = ConcordInviteBundle.buildRevocation(linkSignerPrivKey, createdAt) + /** Parses a shareable invite URL into its pointer + private fragment. */ fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt index ee528efb5b..4f36ea409d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -109,6 +109,22 @@ object ConcordInviteBundle { return signer.sign(ConcordInviteBundleEvent.build(content, createdAt)) } + /** + * Builds the kind-33301 revocation tombstone that retires the link owned by [linkSignerPrivKey] + * (CORD-05 §2). It re-posts the link's own coordinate with empty content and `vsk=9`, so the + * newest event there is a grave rather than keys and [classify] resolves the link + * [InviteBundleStatus.Revoked] for everyone who resolves it afterwards. + * + * Only the creator can do this: the coordinate is addressable and authored by the link signer, + * so retiring a link requires the `link_signer` secret — which lives in the creator's kind-13303 + * Invite List and nowhere else. Losing that secret makes a link permanently un-revokable, which + * is why the list is written before a link is ever handed out. + */ + fun buildRevocation( + linkSignerPrivKey: ByteArray, + createdAt: Long, + ): Event = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)).sign(ConcordInviteBundleEvent.buildRevocation(createdAt)) + /** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */ fun parse( event: Event, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt index 8e17c755fb..b2302cdf68 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt @@ -68,5 +68,22 @@ class ConcordInviteBundleEvent( addUnique(VskTag.assemble(ControlEntityKind.INVITE_LIVE)) initializer() } + + /** + * Builds the revocation tombstone that retires a link: the **same** `["d",""]` coordinate, + * empty content, and `["vsk","9"]` ([ControlEntityKind.INVITE_REVOKED]). + * + * Empty content is the interop contract, not an omission — the spec's "a fetcher finds the + * grave instead of keys", and byte-for-byte what Armada's `buildRevocationEvent` emits. + * There is nothing to encrypt: the point is that no bundle key opens anything here. + */ + fun buildRevocation( + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + dTag("") + addUnique(VskTag.assemble(ControlEntityKind.INVITE_REVOKED)) + initializer() + } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt index 95329c3b98..b0ac0bf1a9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt @@ -29,6 +29,7 @@ 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.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -152,6 +153,40 @@ class ConcordInviteClassifyTest { assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16))) } + @Test + fun buildRevocationEmitsTheWireShapeArmadaEmits() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L) + + // The interop contract, byte for byte: kind 33301 at the SAME addressable coordinate + // (same author, same empty d tag), empty content, vsk=9. Anything else here and a + // non-Amethyst client keeps serving a link its creator believes is dead. + assertEquals(ConcordInviteBundleEvent.KIND, grave.kind) + assertEquals(minted.linkSignerPubKey, grave.pubKey, "a tombstone at a different author retires nothing") + assertEquals("", grave.content, "the grave carries no keys — nothing to encrypt") + assertEquals(listOf(listOf("d", ""), listOf("vsk", "9")), grave.tags.map { it.toList() }) + assertTrue(grave.verify(), "must be signed by the link signer the creator kept") + } + + @Test + fun aBuiltRevocationRetiresItsOwnLink() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + // End to end: what the creator publishes is what every redeemer then resolves. + val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L) + assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.token)) + + // And a re-mint that lands AFTER the grave un-revokes the link, which is exactly why the + // refresh path must skip a coordinate it did not resolve Live first. + val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, inviteFor(community), createdAt = 3L) + assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.token) is InviteBundleStatus.Live) + } + @Test fun realRelayopBundleIsUnreadable() { // The actual kind-33301 event behind the reported relayop.xyz/invite link (vsk=8), plus the From d27930fe761725896e8a7e8459f67da4f82b3429 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Aug 2026 00:57:23 -0400 Subject: [PATCH 11/12] feat(concord): manage and revoke your invite links from Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revoking existed only in `amy` after the last commit, so the app could hand out a link it could never take back. This adds the Android half. `Invite links…` in a community's overflow menu opens a screen listing every link this account minted for it, read from the creator's own kind-13303 Invite List, each row offering Copy and Revoke. It shows only *our* links, because a link's `signer_sk` is what authors its coordinate and only the minting account ever held it — another admin's links are invisible here and un-revokable from here. That is the protocol, not a gap in the screen. Two deliberate choices: The entry point is NOT gated on CREATE_INVITE, unlike minting. Revoking acts on a key we hold rather than on the community, and gating it on the bit would mean a demoted admin could no longer retire the links they had already handed out — exactly when that matters most. An unreadable list is its own state, never an empty one. Telling a creator who came to kill a leaked link that they have no links would be a lie in the one direction that costs them something. `revokeConcordInvite` publishes the wire tombstone first and records the kind-13303 tombstone second, for the same reason the CLI does: the entry holds the only copy of the `signer_sk` the publish needs, and a merge drops a tombstoned token's entry terminally. A failed list write is reported as success because the link is already dead on the wire. Verified on a tablet against a local relay, cross-client with amy: the screen lists the two links the device minted (and not the one alice minted), the confirm dialog revokes exactly one coordinate — flipping it to vsk=9 with empty content while its siblings stay vsk=6 — the row disappears on reload, and a link revoked from the UI is then refused by `amy concord join` with `revoked` while the surviving link still joins. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/model/AccountConcordActions.kt | 69 +++++ .../amethyst/ui/navigation/AppNavigation.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../concord/ConcordChannelListScreen.kt | 11 + .../concord/ConcordInviteLinksScreen.kt | 273 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 11 + 6 files changed, 377 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index daad3aa9c7..35816e52ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys @@ -339,6 +340,74 @@ class AccountConcordActions( return minted.url } + /** + * Every link this account minted for [communityId] that is still live, newest first — the + * backing list for the invite-links screen. + * + * Null means the list could not be read (no relay answered, or the signer refused the decrypt), + * which the UI must show as an error rather than as "you have no links": telling a creator their + * leaked link doesn't exist is worse than telling them we couldn't check. + * + * Retired tokens are filtered out here rather than rendered as dead rows — [ConcordInviteList] + * already drops a tombstoned entry on merge, so a tombstoned entry only appears in the window + * between our revoke and the next merge. + */ + suspend fun listConcordInviteLinks(communityId: String): List? { + val list = readConcordInviteList() ?: return null + val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } + return list.entries + .filter { it.communityId == communityId && it.token !in tombstoned } + .sortedByDescending { it.createdAt } + } + + /** + * Retires the link [token] (CORD-05 §2): publishes a `vsk=9` tombstone at its coordinate, then + * records the retirement in the kind-13303 list. Returns false if the link could not be retired. + * + * No community permission is checked, deliberately. The coordinate is authored by the link + * signer, whose secret only the creator holds, so revoking is an act on your own key rather than + * on the community — and gating it on CREATE_INVITE would mean a demoted admin could no longer + * retire the links they had already handed out, which is precisely when they most need to. + * + * The wire tombstone goes first and the list second. That is the inverse of minting and it is + * deliberate: the entry holds the only copy of the `signer_sk` this needs, and a merge drops a + * tombstoned token's entry terminally, so recording first and then failing to publish would + * leave the link live with its signer gone and no way left to retire it. A failed list write is + * recoverable — the link is already dead on the wire, and the refresh path re-mints only a + * coordinate that still resolves Live. + */ + suspend fun revokeConcordInvite( + communityId: String, + token: String, + ): Boolean { + if (!account.isWriteable()) return false + val entry = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } ?: return false + val link = + readConcordInviteList()?.entries?.firstOrNull { it.token == token && it.communityId == communityId } + ?: run { + Log.w("Concord") { "Cannot revoke $token: it is not in this account's invite list, so its link signer is unknown" } + return false + } + + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } + if (relays.isEmpty()) return false + val published = + runCatching { + account.client.publish(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays) + true + }.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false) + if (!published) return false + + if (!publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId))))) { + // The link is already dead on the wire, so this is bookkeeping we can retry rather than a + // failed revocation. Reported as success for exactly that reason. + Log.w("Concord") { "Revoked $token on the wire but could not tombstone it in the invite list; a later revoke will record it" } + } + return true + } + /** Drop a joined Concord community from the private kind-13302 list by its id. */ suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index ee1ace2d8d..157a12d191 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -138,6 +138,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteLinksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen @@ -747,6 +748,14 @@ fun BuildNavigation( ) } + composableFromEndArgs { + ConcordInviteLinksScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { ConcordEditScreen( communityId = it.communityId, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b1afa1c5b9..c267a23c73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -830,6 +830,10 @@ sealed class Route { val communityId: String, ) : Route() + @Serializable data class ConcordInviteLinks( + val communityId: String, + ) : Route() + @Serializable object ConcordCreate : Route() // Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index d33dfee5e2..57dffc1084 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -294,6 +294,17 @@ fun ConcordChannelListScreen( SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options)) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + // Deliberately not gated on CREATE_INVITE, unlike minting: the links listed + // there are this account's own, authored by link-signer keys only we hold. + // Gating on the bit would mean a demoted admin could no longer retire the + // links they had already handed out — exactly when that matters most. + DropdownMenuItem( + text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_links_action)) }, + onClick = { + menuOpen = false + nav.nav(Route.ConcordInviteLinks(communityId)) + }, + ) DropdownMenuItem( text = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt new file mode 100644 index 0000000000..ef10b8c2bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt @@ -0,0 +1,273 @@ +/* + * 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.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** What the screen is currently showing. The unreadable case is deliberately not "empty" — see below. */ +private sealed interface LinksState { + data object Loading : LinksState + + data class Loaded( + val links: List, + ) : LinksState + + /** + * The kind-13303 list could not be read. Distinct from an empty list on purpose: rendering + * "no links yet" here would tell a creator that the link they came to kill does not exist. + */ + data object Unreadable : LinksState +} + +/** + * Every invite link this account minted for one community, with the ability to retire one + * (CORD-05 §2). + * + * The list is the creator's own kind-13303 Invite List, which is where a link's `signer_sk` lives — + * so this shows only links *this account* minted, from any of its devices. Another admin's links are + * invisible here and un-revokable from here, because the secret that authors their coordinate was + * never ours. That is a property of the protocol, not a gap in the screen. + * + * Fetched on entry rather than collected from a flow: nothing subscribes to kind 13303 (it is + * bookkeeping the user never sees), so there is no cache to observe. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordInviteLinksScreen( + communityId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + + var state by remember(communityId) { mutableStateOf(LinksState.Loading) } + var reloads by remember(communityId) { mutableIntStateOf(0) } + var confirming by remember { mutableStateOf(null) } + var revoking by remember { mutableStateOf(false) } + + LaunchedEffect(communityId, reloads) { + state = LinksState.Loading + state = account.concord.listConcordInviteLinks(communityId)?.let { LinksState.Loaded(it) } ?: LinksState.Unreadable + } + + val communityName = + remember(account, communityId) { + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } + ?.name + .orEmpty() + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(stringRes(R.string.concord_invite_links_title), fontWeight = FontWeight.Bold) + if (communityName.isNotBlank()) { + Text(communityName, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + ) + }, + ) { padding -> + when (val current = state) { + is LinksState.Loading -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + is LinksState.Unreadable -> CenteredMessage(padding, stringRes(R.string.concord_invite_links_unreadable)) + + is LinksState.Loaded -> + if (current.links.isEmpty()) { + CenteredMessage(padding, stringRes(R.string.concord_invite_links_empty)) + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(current.links, key = { it.token }) { link -> + InviteLinkRow( + link = link, + enabled = !revoking, + onCopy = { scope.launch { clipboard.setText(link.url) } }, + onRevoke = { confirming = link }, + ) + HorizontalDivider() + } + } + } + } + } + + confirming?.let { link -> + AlertDialog( + onDismissRequest = { if (!revoking) confirming = null }, + title = { Text(stringRes(R.string.concord_invite_revoke_title)) }, + text = { Text(stringRes(R.string.concord_invite_revoke_explainer)) }, + confirmButton = { + TextButton( + enabled = !revoking, + onClick = { + revoking = true + scope.launch { + try { + val ok = account.concord.revokeConcordInvite(communityId, link.token) + accountViewModel.toastManager.toast( + R.string.concord_invite_links_title, + if (ok) R.string.concord_invite_revoked_ok else R.string.concord_invite_revoked_failed, + ) + // Re-read either way: on success the link is gone from the list, and on + // failure the list is the only thing that can say whether it changed. + reloads++ + } finally { + revoking = false + confirming = null + } + } + }, + ) { + Text(stringRes(R.string.concord_invite_revoke_confirm), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(enabled = !revoking, onClick = { confirming = null }) { + Text(stringRes(R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun CenteredMessage( + padding: PaddingValues, + message: String, +) { + Box(Modifier.fillMaxSize().padding(padding).padding(24.dp), contentAlignment = Alignment.Center) { + Text( + message, + // This Box sits on the bare window background, so LocalContentColor is still the M3 + // default black — see the sibling invite screen, where that made the text invisible. + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Composable +private fun InviteLinkRow( + link: ConcordInviteListEntry, + enabled: Boolean, + onCopy: () -> Unit, + onRevoke: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f).padding(end = 8.dp)) { + // The token prefix is what tells two links to the same community apart; their URLs share + // a long prefix, so they are useless as labels until well past where the row wraps. + Text(link.token.take(8), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge) + Text( + stringRes(R.string.concord_invite_links_created, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(link.createdAt * 1000))), + style = MaterialTheme.typography.bodySmall, + ) + Text(link.url, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + IconButton(enabled = enabled, onClick = { menuOpen = true }) { + SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringRes(R.string.copy_to_clipboard)) }, + onClick = { + menuOpen = false + onCopy() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.concord_invite_revoke_action), color = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + onRevoke() + }, + ) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 20e32ea0b7..9489821e4e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -322,6 +322,17 @@ 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. This invite link has been revoked and can no longer be used. Ask for a new one. This community has removed you. The link still works, but its member list does not admit you. + Invite links + Invite links… + Created %1$s + You haven\'t created any invite links for this community yet. Links other admins created are managed on their own devices. + Your invite links couldn\'t be loaded, so none can be revoked right now. Check your connection and try again. + Revoke link + Revoke this link? + Anyone still holding this link will no longer be able to join. People who already joined with it stay in the community. This can\'t be undone. + Revoke + Invite link revoked. + The link couldn\'t be revoked. Check your connection and try again. This invite link has expired and can no longer be used. Ask for a fresh link. Community name is only revealed after you join 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. From 70c53a8fcddd267c958bbb752965a3c08087a9df Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Aug 2026 10:25:25 -0400 Subject: [PATCH 12/12] fix(concord): make the invite-list writes actually durable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A high-effort audit of the branch found that the durability guarantees the previous commits claimed were not the guarantees the code provided. Three of these are in the code written to close the last review, and they defeat exactly what those commits set out to fix. `INostrClient.publish` returns Unit — it queues an event and never reports acceptance; `publishAndConfirm` is the confirming variant. So every `runCatching { publish(...); true }` was true whenever local signing worked. That made minting's "record the link before handing out the URL" gate decorative, and made revoke worse than decorative: it reported success for a tombstone no relay stored, then recorded the kind-13303 tombstone, whose merge drops the entry — destroying the only `signer_sk` that could ever retire the link while the link stayed live. Both paths, and the Refounding re-mint, now confirm. `fetchAll` returns an empty list on cannot-connect / CLOSED / idle-timeout, so "a relay served us and had nothing" and "nobody answered" were the same observation. Reading the second as "no list yet" reintroduced, one layer below, the wipe the null-vs-empty work existed to prevent. `fetchAllWithHooks` gains a `doneOut` of per-relay terminal reasons plus `anyRelayServed()`, and both clients now only treat an empty read as an empty list when a relay actually reached EOSE. The rest: - `drainConcordRekeys` discarded the entry `adoptConcordRoot` now returns, so only the account that *launched* a rotation re-minted its links. An admin who was merely re-keyed left every link they had handed out on the dead root, and anyone stranded behind one could never recover — which is the branch's headline goal, holding only for the rotator. - The join-time ban gate fetched the Control Plane from `bundle.relays` alone (stale metadata refuses a community we can plainly reach) with a single un-paged REQ (truncated at the relay's filter cap, so a missing older ban edition fails the gate OPEN, re-admitting the account it exists to refuse). Now unions in the relays that just served the bundle, and pages. - `decodeOrNull` failed the whole document for one structurally incompatible entry. Since null now means "refuse to write", that converted the old silent data loss into a permanent write lock on a coordinate that never ages out. Unreadable entries are carried verbatim instead, so they neither block the account nor get dropped on re-encode. - The list read took the newest event of any kind and then cast, so one stray event at the coordinate read as "unreadable" forever. Filters by kind first. - The Refounding refresh did one full round trip per link, serially, inside a user-visible rotation. One pooled REQ over every link signer, then concurrent confirmed re-mints, classified per coordinate so one link's tombstone cannot decide another's status. Verified on a tablet: mint, list, revoke and the cross-client refusal still work end to end — and with the community relay killed, revoke now reports "The link couldn't be revoked" and leaves the entry intact, where before it would have claimed success and destroyed the key. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/model/AccountConcordActions.kt | 138 +++++++++++++----- .../com/vitorpamplona/amethyst/cli/Context.kt | 3 + .../amethyst/cli/commands/ConcordCommands.kt | 15 +- .../commons/actions/ConcordActions.kt | 10 ++ .../cord05Invites/ConcordInviteList.kt | 96 +++++++++--- .../NostrClientFetchAllWithHooksExt.kt | 21 +++ .../cord05Invites/ConcordInviteListTest.kt | 37 +++++ 7 files changed, 255 insertions(+), 65 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index 35816e52ce..1bf9339ae4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -54,8 +54,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll 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.publishAndConfirm import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -65,6 +68,9 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import java.util.concurrent.ConcurrentHashMap /** Name of the default Concord community Admin role minted by "Make admin". */ @@ -191,12 +197,29 @@ class AccountConcordActions( val relays = account.outboxRelays.flow.value if (relays.isEmpty()) return null val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(account.signer.pubKey)) + // Terminal reasons, not just events: `fetchAll` returns an empty list both when a relay + // served us and had nothing AND when nothing answered at all (cannot-connect, CLOSED, idle + // timeout). Treating the second as "no list yet" is precisely how a read-merge-write wipes + // the signer_sk of every link it failed to read, so the two must be told apart. + val reasons = mutableMapOf() + val events = + account.client.fetchAllWithHooks( + filters = relays.associateWith { listOf(filter) }, + doneOut = reasons, + ) { _, _ -> true } + val newest = - account.client - .fetchAll(filters = relays.associateWith { listOf(filter) }) + events + .mapNotNull { it.second as? ConcordInviteListEvent } + // Filter by kind BEFORE picking the newest: taking the newest of anything and then + // casting means one stray event at this coordinate reads as "unreadable" forever. .maxByOrNull { it.createdAt } - ?: return ConcordInviteListDocument.EMPTY // nothing published yet — safe to start one - return (newest as? ConcordInviteListEvent)?.decrypt(account.signer) + ?: return if (reasons.anyRelayServed()) { + ConcordInviteListDocument.EMPTY // a relay answered and had nothing — safe to start one + } else { + null // nobody answered; we know nothing about what is published + } + return newest.decrypt(account.signer) } /** @@ -216,9 +239,11 @@ class AccountConcordActions( Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" } return false } + // publishAndConfirm, never publish: `INostrClient.publish` returns Unit — it queues the event + // and never reports acceptance — so a `runCatching { publish(); true }` is true whenever + // local signing worked, and every caller's "did the record land?" gate becomes decorative. return runCatching { - account.client.publish(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo) - true + account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo) }.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false) } @@ -243,30 +268,43 @@ class AccountConcordActions( val list = readConcordInviteList() ?: return 0 val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } val now = TimeUtils.now() - var count = 0 - for (link in list.entries) { - if (link.communityId != entry.id) 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 { - 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) } + + // An elapsed or retired link can no longer be joined; re-posting it would only resurrect a + // dead URL at a live epoch. + val links = list.entries.filter { it.communityId == entry.id && !it.isExpired(now) && it.token !in tombstoned } + if (links.isEmpty()) return 0 + + // One REQ for every link's bundle rather than a round trip each. This runs inside the + // user-visible Refounding, and a serial fetch per link makes a removal take time linear in + // how many links the creator ever minted, each able to wait out its own idle timeout. + val byAuthor = links.associateBy { it.signerPubKeyHex().lowercase() } + val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.bundlesFilter(byAuthor.keys.toList())) }) + val wrapsByAuthor = wraps.groupBy { it.pubKey.lowercase() } + + return coroutineScope { + byAuthor + .map { (author, link) -> + async { + runCatching { + val token = link.token.hexToByteArray() + // Classify per coordinate, never over the pooled set: one link's newer + // revocation tombstone must not decide another link's status. + val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), token) as? InviteBundleStatus.Live ?: return@runCatching false + val moved = + current.invite.copy( + communityRoot = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk, + relays = entry.relays, + ) + // Confirmed: a link counted as moved but never stored is a link its + // holders can no longer redeem, reported as a success. + account.client.publishAndConfirm(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays) + }.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) }.getOrDefault(false) + } + }.awaitAll() + .count { it } } - return count } /** @@ -393,10 +431,12 @@ class AccountConcordActions( val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } if (relays.isEmpty()) return false + // Confirmed, not fire-and-forget. A `publish` that returns Unit would report success for a + // tombstone no relay stored — and the list write below would then drop this entry on merge, + // destroying the only `signer_sk` that could ever retire the link while the link stays live. val published = runCatching { - account.client.publish(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays) - true + account.client.publishAndConfirm(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays) }.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false) if (!published) return false @@ -477,7 +517,15 @@ class AccountConcordActions( // 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. + // the root, and no verdict means no join. Two things make that safe to insist on rather than + // a way to brick valid invites: + // + // - the plane is fetched over the SAME relays that just served the bundle, not the relay + // list inside the bundle alone, which can be stale (a moved relay, a link minted before a + // relay change) and would otherwise refuse a community we can plainly reach; + // - it is PAGED, because a single REQ is truncated at the relay's per-filter cap. A missing + // older ban edition fails the gate open — it re-admits the very account it exists to + // refuse — so the one direction we must not economise on is completeness. val joinKeys = ConcordActions.controlPlaneKeys( communityRoot = bundle.communityRoot.hexToByteArray(), @@ -485,12 +533,14 @@ class AccountConcordActions( 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, - ) + // Union, not `ifEmpty`: the relays that served the bundle are known-good for this community, + // and the bundle's own list is the one that goes stale. + val joinRelays = bundle.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + relays + val planeWraps = mutableListOf() + account.client.fetchAllPagesFromPool( + filters = joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, + ) { event, _ -> planeWraps.add(event) } + val joinEditions = ConcordActions.controlEditions(planeWraps, joinKeys) if (joinEditions.isEmpty()) return ConcordInviteResult.NotReachable if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(account.signer.pubKey)) { return ConcordInviteResult.Banned @@ -1210,7 +1260,17 @@ class AccountConcordActions( // who has themselves been banned could still rotate the whole community. val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) if (!authorized) continue - adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + 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}" } + } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 02b482bebf..185cbc7f6d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -582,12 +582,15 @@ class Context( diagnoseSlow: Boolean = false, deadOut: MutableMap? = null, pendingOnAuthRequired: Boolean = false, + /** Per-relay terminal reason, so a caller can tell an empty answer from no answer. */ + doneOut: MutableMap? = null, ): List> = client.fetchAllWithHooks( filters = filters, idleTimeoutMs = idleTimeoutMs, pendingOnAuthRequired = pendingOnAuthRequired, deadOut = deadOut, + doneOut = doneOut, onTimeout = if (diagnoseSlow) { { stalled, doneReasons, collected -> logSlowDrain(idleTimeoutMs, stalled, doneReasons, collected) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 77047c4e5b..51ad02fd7e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -724,13 +725,19 @@ object ConcordCommands { val relays = ctx.outboxRelays() if (relays.isEmpty()) return null val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(ctx.signer.pubKey)) + // Terminal reasons, not just events: a drain returns nothing both when a relay served us and + // had nothing AND when nobody answered. Reading the second as "no list yet" is how the + // read-merge-write below wipes the signer_sk of every link it failed to read. + val reasons = mutableMapOf() val newest = ctx - .drain(relays.associateWith { listOf(filter) }) - .map { it.second } + .drain(relays.associateWith { listOf(filter) }, doneOut = reasons) + // Filter by kind BEFORE picking the newest — a stray event at this coordinate would + // otherwise make the list read as unreadable and refuse every later write. + .mapNotNull { it.second as? ConcordInviteListEvent } .maxByOrNull { it.createdAt } - ?: return ConcordInviteListDocument.EMPTY // nothing published yet — safe to start one - return (newest as? ConcordInviteListEvent)?.decrypt(ctx.signer) + ?: return if (reasons.anyRelayServed()) ConcordInviteListDocument.EMPTY else null + return newest.decrypt(ctx.signer) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index dfb51935d7..e11a4278fa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -201,6 +201,16 @@ object ConcordActions { /** The public invite bundle for a link signer. */ fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = listOf(linkSignerPubKeyHex)) + /** + * The bundles of several links at once — one REQ over every link signer instead of a round trip + * per link, which is what a Refounding needs when it re-mints a creator's whole set. + * + * Partition the result by `pubKey` before classifying: [ConcordInviteBundle.classify] resolves a + * single coordinate, so handing it a pooled set would let one link's revocation tombstone decide + * another link's status purely by being newer. + */ + fun bundlesFilter(linkSignerPubKeyHexes: List): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = linkSignerPubKeyHexes) + /** Pending direct invites addressed to the given member (indexed by k=3313). */ fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString()))) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt index 5b930eb261..f136aed3fd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt @@ -30,9 +30,11 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject private val NoExtras: JsonObject = JsonObject(emptyMap()) @@ -77,11 +79,19 @@ class ConcordInviteListTombstone( val residue: JsonObject = NoExtras, ) -/** The decoded kind-13303 document: live [entries], [tombstones], and document-level [residue]. */ +/** + * The decoded kind-13303 document: live [entries], [tombstones], and document-level [residue]. + * + * [opaqueEntries] holds entries that did not type-check — a wrong-typed field from another client or + * a newer schema. They are carried verbatim rather than dropped (re-encoding without them would + * delete somebody's `signer_sk`) and rather than failing the whole read (which would refuse every + * future mint and revoke for this account until someone else repaired the list). + */ class ConcordInviteListDocument( val entries: List = emptyList(), val tombstones: List = emptyList(), val residue: JsonObject = NoExtras, + val opaqueEntries: List = emptyList(), ) { companion object { val EMPTY = ConcordInviteListDocument() @@ -160,41 +170,80 @@ object ConcordInviteList { private object WireDocumentSerializer : ExtrasPreserving(WireDocument.serializer()) /** - * Decodes the plaintext document, or **null** when it cannot be parsed. + * Decodes the plaintext document, or **null** when the document itself cannot be read. * * 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. + * callers). + * + * Null is reserved for a *document-level* failure — not JSON, or `entries`/`tombstones` present + * but not arrays. A single entry that does not type-check is kept verbatim in + * [ConcordInviteListDocument.opaqueEntries] instead: failing the whole read for one odd row + * would refuse every future mint and revoke for the account, permanently, since a replaceable + * coordinate never ages out — turning the old silent data loss into a permanent write lock. */ fun decodeOrNull(json: String): ConcordInviteListDocument? = try { - val doc = ConcordJson.instance.decodeFromString(WireDocumentSerializer, json) - ConcordInviteListDocument( - entries = - doc.entries.map { + val root = ConcordJson.instance.parseToJsonElement(json).jsonObject + val opaque = mutableListOf() + + val entries = + (root["entries"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element -> + val obj = element.jsonObject + try { + val it = ConcordJson.instance.decodeFromJsonElement(WireEntrySerializer, obj) ConcordInviteListEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.extras) - }, - tombstones = doc.tombstones.map { ConcordInviteListTombstone(it.token, it.communityId, it.extras) }, - residue = doc.extras, + } catch (_: Exception) { + opaque.add(obj) + null + } + } + + val tombstones = + (root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element -> + try { + val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject) + ConcordInviteListTombstone(it.token, it.communityId, it.extras) + } catch (_: Exception) { + // A tombstone we cannot read must not silently un-retire its link, but we + // have no token to key it by, so it can only ride along as document residue. + null + } + } + + ConcordInviteListDocument( + entries = entries, + tombstones = tombstones, + residue = JsonObject(root - "entries" - "tombstones"), + opaqueEntries = opaque, ) } catch (_: Exception) { null } - fun encode(doc: ConcordInviteListDocument): String = - ConcordJson.instance.encodeToString( - WireDocumentSerializer, - WireDocument( - entries = - doc.entries.map { - WireEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.residue) - }, - tombstones = doc.tombstones.map { WireTombstone(it.token, it.communityId, it.residue) }, - extras = doc.residue, - ), - ) + fun encode(doc: ConcordInviteListDocument): String { + val wire = + ConcordJson.instance + .encodeToJsonElement( + WireDocumentSerializer, + WireDocument( + entries = + doc.entries.map { + WireEntry(it.token, it.signerSk, it.communityId, it.url, it.label, it.createdAt, it.expiresAt, it.residue) + }, + tombstones = doc.tombstones.map { WireTombstone(it.token, it.communityId, it.residue) }, + extras = doc.residue, + ), + ).jsonObject + + // Entries we could not type ride back out untouched. Dropping them here is the data loss + // this whole class exists to prevent — they are somebody's link signer too. + if (doc.opaqueEntries.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire) + val entries = JsonArray((wire["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries) + return ConcordJson.instance.encodeToString(JsonObject.serializer(), JsonObject(wire + ("entries" to entries))) + } /** * Merges [patch] onto [base], keyed by `token` — the spec's own merge key. A token present in @@ -218,6 +267,9 @@ object ConcordInviteList { entries = entries.values.toList(), tombstones = tombstones.values.toList(), residue = JsonObject(base.residue + patch.residue), + // Untyped entries survive the merge for the same reason they survive a decode: we cannot + // read them, so we are in no position to decide they are disposable. + opaqueEntries = (base.opaqueEntries + patch.opaqueEntries).distinct(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt index 62d13ce5e9..f28864dca0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllWithHooksExt.kt @@ -79,6 +79,14 @@ suspend fun INostrClient.fetchAllWithHooks( subscriptionId: String = newSubId(), pendingOnAuthRequired: Boolean = false, deadOut: MutableMap? = null, + /** + * Receives the terminal reason per relay ("eose", "closed:…", "cannot:…"), so a caller can + * tell "a relay served us and had nothing" from "nobody served us". An empty result alone + * cannot: both look like zero events, and treating the second as the first is how a + * read-merge-write on a replaceable event destroys the entries it failed to read. See + * [anyRelayServed]. + */ + doneOut: MutableMap? = null, onTimeout: ((stalled: Set, doneReasons: Map, collected: List>) -> Unit)? = null, /** * Hard wall-clock ceiling. The idle window alone is unbounded when a relay @@ -237,9 +245,22 @@ suspend fun INostrClient.fetchAllWithHooks( classifyDrainFailure(reason)?.let { out[relay] = it } } } + doneOut?.putAll(doneReasons) return collected } +/** The terminal reason recorded when a relay finished serving a subscription normally. */ +const val DONE_REASON_EOSE = "eose" + +/** + * True when at least one relay completed the fetch normally, i.e. answered and reached EOSE. + * + * Read against the map filled by `fetchAllWithHooks`'s `doneOut`. An empty event list means + * "nothing matched" only when this is true; otherwise it means "nobody told us", and a caller + * that overwrites a replaceable event on that basis deletes whatever it could not read. + */ +fun Map.anyRelayServed(): Boolean = values.any { it == DONE_REASON_EOSE } + /** * [fetchAllPagesFromPool] with a suspending per-event hook: paginates every relay * to completion (each on its own `until` cursor, up to [maxConcurrentRelays] at diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt index 7a3904bdbc..b3a6dbde5e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt @@ -123,6 +123,43 @@ class ConcordInviteListTest { assertEquals(null, ConcordInviteList.decodeOrNull("{\"entries\":\"wrong type\"}")) } + @Test + fun oneUnreadableEntryDoesNotFailTheWholeDocumentOrGetDropped() { + // One structurally incompatible entry — a newer schema turning a scalar into an object, the + // realistic version, since the lenient parser already coerces plain scalar mismatches — used + // to null the whole document. Because null now means "refuse to write", that turned a single + // odd row into a permanent lock on mint and revoke for the account: a replaceable coordinate + // never ages out, so nothing would ever clear it. + val mixed = + """ + { "entries": [ + { "token": "aa", "signer_sk": "bb", "community_id": "cc", "url": "u1" }, + { "token": {"v": "dd"}, "signer_sk": "dd", "community_id": "cc", "url": "u2", "mark": "keepme" } + ], + "tombstones": [] } + """.trimIndent() + + val doc = ConcordInviteList.decodeOrNull(mixed) + assertEquals(listOf("aa"), doc!!.entries.map { it.token }, "the readable entry still decodes") + assertEquals(1, doc.opaqueEntries.size, "the unreadable entry is kept, not discarded") + + // And it survives a re-encode: dropping it would delete somebody's signer_sk, which is the + // exact data loss this class exists to prevent. + assertTrue(ConcordInviteList.encode(doc).contains("keepme"), "unreadable entry lost on re-encode") + } + + @Test + fun aMergeCarriesUnreadableEntriesThrough() { + val base = ConcordInviteList.decodeOrNull("""{"entries":[{"token":{"v":7},"mark":"opaque"}],"tombstones":[]}""")!! + val patch = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t", "sk", "c", "u"))) + + val merged = ConcordInviteList.merge(base, patch) + + assertEquals(listOf("t"), merged.entries.map { it.token }) + // We cannot read it, so we are in no position to decide it is disposable. + assertTrue(ConcordInviteList.encode(merged).contains("opaque"), "merge dropped an unreadable entry") + } + @Test fun anUnreadableListIsDistinguishableFromAnEmptyOne() { // The whole point of the null: a caller must be able to tell "I could not read it" from