feat(cli): add amy concord revoke — retire an invite link (CORD-05)

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-10 00:30:09 -04:00
co-authored by Claude Opus 5
parent a1f980babd
commit fc3f181184
8 changed files with 190 additions and 4 deletions
+1
View File
@@ -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`). |
+1 -1
View File
@@ -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). |
@@ -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 `<data-dir>/shared/`):
@@ -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 <create|list|import|channels|send|read|invite|join|recover|rekey|roles|role|grant|ban|unban|refound>",
"concord <create|list|import|channels|send|read|invite|revoke|join|recover|rekey|roles|role|grant|ban|unban|refound>",
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 <community> <token|url>` 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<String>,
): 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<String>,
@@ -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
@@ -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)
@@ -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,
@@ -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<ConcordInviteBundleEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
dTag("")
addUnique(VskTag.assemble(ControlEntityKind.INVITE_REVOKED))
initializer()
}
}
}
@@ -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