From fc3f181184f4a034db604762fa7921f62d03d079 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Aug 2026 00:30:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20add=20`amy=20concord=20revoke`=20?= =?UTF-8?q?=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