fix(concord): resolve invite coordinate per CORD-05 (honor revocation)

Follow-up making the invite redeemer match the CORD-05 §2 spec for the
addressable invite coordinate (33301, link_signer, d=""):

- vsk=6 → live bundle (open with the link token)
- vsk=9 → revocation tombstone: the newest event wins, so a tombstone buries
  even a stale, still-openable copy on another relay ("a fetcher finds the
  grave instead of keys"). Amethyst previously never checked for this, so a
  revoked link failed generically.
- anything else present (e.g. a mis-posted registry vsk=8, the shape of the
  relayop.xyz link that hung) → unreadable
- nothing on any relay → absent

New pure `ConcordInviteBundle.classify(wraps, token): InviteBundleStatus` in
quartz (next to parse/validate), wrapped by `ConcordActions.classifyInvite`,
and mapped by `Account.joinConcordViaInvite` to the `ConcordInviteResult`
cases — including a new `Revoked` outcome with its own message and no futile
retry. Crypto is unchanged and already matches the spec
(hkdf(token,'concord/invite-key') → NIP-44 → snake_case CommunityInvite).

Adds ConcordInviteClassifyTest covering live / revoked (order-independent) /
unreadable / absent, plus the real relayop.xyz vsk=8 event → Unreadable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD
This commit is contained in:
Claude
2026-07-16 01:24:37 +00:00
parent 389d750ccc
commit ae9f4a0def
7 changed files with 205 additions and 8 deletions
@@ -157,6 +157,7 @@ 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.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
@@ -2054,14 +2055,17 @@ class Account(
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
val wraps = client.fetchAll(filters = filters)
val bundle = wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) }
if (bundle == null) {
// The filter matches only kind-33301 bundles authored by this link signer, so any wrap
// that came back IS a bundle we simply couldn't open — a wrong token or, as the reference
// client evolves, a bundle format newer than we support. Distinguish that from "nothing on
// any relay" so the UI skips the pointless retry on an incompatible link.
return if (wraps.isNotEmpty()) ConcordInviteResult.Incompatible else ConcordInviteResult.NotReachable
}
// Resolve the coordinate per CORD-05 §2 (newest wins; a vsk=9 tombstone revokes even over a
// stale openable copy) so we honour revocation and can tell the user *why* a link won't open
// instead of stranding them on a spinner that retries a link we can never redeem.
val bundle =
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
is InviteBundleStatus.Live -> status.invite
InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked
InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible
InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable
}
val entry =
ConcordCommunityListEntry(
@@ -41,6 +41,12 @@ sealed interface ConcordInviteResult {
*/
data object NotReachable : ConcordInviteResult
/**
* The link was revoked: the newest event at its coordinate is a `vsk=9` revocation
* tombstone (CORD-05 §2). Retrying can't help the owner retired this link.
*/
data object Revoked : 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
@@ -85,6 +85,8 @@ fun ConcordInviteScreen(
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_invalid, canRetry = false)
is ConcordInviteResult.Incompatible ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_incompatible, canRetry = false)
is ConcordInviteResult.Revoked ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_revoked, canRetry = false)
is ConcordInviteResult.NotReachable ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed, canRetry = true)
}
+1
View File
@@ -311,6 +311,7 @@
<string name="concord_invite_failed">Could not fetch this invite. The link may be expired or its relays unreachable.</string>
<string name="concord_invite_failed_invalid">This invite link is invalid or can\'t be opened with this account.</string>
<string name="concord_invite_failed_incompatible">This invite was created with a newer version of the app and can\'t be opened here yet. Ask for an updated link or try again after updating.</string>
<string name="concord_invite_failed_revoked">This invite link has been revoked and can no longer be used. Ask for a new one.</string>
<string name="concord_home_title">Concord Channels</string>
<string name="concord_home_empty">You haven\'t joined any Concord Channels yet. Create one, or open an invite link.</string>
<string name="concord_channels_empty">No channels yet.</string>
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
@@ -314,6 +315,17 @@ object ConcordActions {
token: ByteArray,
): CommunityInvite? = ConcordInviteBundle.parse(bundleEvent, token)?.takeIf { ConcordInviteBundle.validate(it) }
/**
* Resolves every event fetched at an invite's addressable coordinate into one
* [InviteBundleStatus] (live / revoked / unreadable / absent) per CORD-05 §2, so a
* redeeming client honours a `vsk=9` revocation tombstone and reports why a link
* can't be opened instead of retrying blindly.
*/
fun classifyInvite(
wraps: List<Event>,
token: ByteArray,
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token)
/** Derives the control plane described by a redeemed [invite] so the joiner can read it. */
fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch)
@@ -21,6 +21,8 @@
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -31,6 +33,34 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip44Encryption.Nip44
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* What the events fetched at an invite's addressable coordinate `(33301,
* link_signer, d="")` actually resolve to under CORD-05 §2. The coordinate is
* replaceable, so its live state is the **newest** event: a revocation tombstone
* (`vsk=9`) supersedes and buries the bundle even if an older openable copy still
* lingers on some relay, which is exactly the "fetcher finds the grave instead of
* keys" behaviour the spec mandates.
*/
sealed interface InviteBundleStatus {
/** A live `vsk=6` bundle that opened with the link token. */
data class Live(
val invite: CommunityInvite,
) : InviteBundleStatus
/** The newest event at the coordinate is a `vsk=9` revocation tombstone — the link was retired. */
data object Revoked : InviteBundleStatus
/**
* Something is at the coordinate, but it isn't a `vsk=6` bundle this client can open
* a wrong/expired token, or a sub-kind (e.g. a mis-posted registry `vsk=8`) or format
* newer than we support.
*/
data object Unreadable : InviteBundleStatus
/** Nothing was found at the coordinate on any queried relay (unreachable, expired, or not yet propagated). */
data object Absent : InviteBundleStatus
}
/** A freshly minted public invite link: the shareable URL, the link keys, and the bundle to publish. */
class MintedInviteLink(
val url: String,
@@ -82,6 +112,25 @@ object ConcordInviteBundle {
}
}
/**
* Resolves the events fetched at an invite's addressable coordinate into a single
* [InviteBundleStatus] under CORD-05 §2 replaceable semantics. The newest event
* wins: a `vsk=9` revocation tombstone marks the link [InviteBundleStatus.Revoked]
* even when an older, still-openable bundle is also present (so a stale relay copy
* can't resurrect a retired link). Otherwise the first `vsk=6` bundle that opens +
* validates with [token] is [InviteBundleStatus.Live]; anything else present is
* [InviteBundleStatus.Unreadable], and an empty set is [InviteBundleStatus.Absent].
*/
fun classify(
wraps: List<Event>,
token: ByteArray,
): InviteBundleStatus {
val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent
if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked
val invite = wraps.firstNotNullOfOrNull { parse(it, token)?.takeIf { i -> validate(i) } }
return if (invite != null) InviteBundleStatus.Live(invite) else InviteBundleStatus.Unreadable
}
/**
* Validates that an [invite]'s owner + salt actually reproduce its
* community_id (CORD-02 self-certification), so a bundle can't smuggle a false
@@ -0,0 +1,123 @@
/*
* 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.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Resolving an invite's addressable coordinate per CORD-05 §2: a live `vsk=6` bundle
* opens, a `vsk=9` revocation tombstone wins even over a still-openable stale copy,
* an unknown/mis-posted sub-kind is unreadable, and an empty fetch is absent.
*/
class ConcordInviteClassifyTest {
private val owner = NostrSignerInternal(KeyPair())
private fun inviteFor(community: NewConcordCommunity) =
CommunityInvite(
communityId = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
communityRoot = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
relays = listOf("wss://relay.example"),
name = "Nostrichs",
)
/** A raw kind-33301 event at the link-signer coordinate carrying an arbitrary [vsk] wire value. */
private fun coordinateEvent(
linkSignerPubKey: String,
vsk: String,
createdAt: Long,
content: String = "",
) = Event(
id = "00".repeat(32),
pubKey = linkSignerPubKey,
createdAt = createdAt,
kind = ConcordInviteBundleEvent.KIND,
tags = arrayOf(arrayOf("d", ""), VskTag.TAG_NAME.let { arrayOf(it, vsk) }),
content = content,
sig = "00".repeat(64),
)
@Test
fun liveBundleOpens() =
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 status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token)
assertTrue(status is InviteBundleStatus.Live)
assertEquals(community.communityIdHex, status.invite.communityId)
}
@Test
fun revocationTombstoneWinsOverStaleBundle() =
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"))
// A newer vsk=9 tombstone at the same coordinate buries the still-openable bundle.
val tombstone = coordinateEvent(minted.linkSignerPubKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.token))
// Order of the fetched list must not matter — newest createdAt wins regardless.
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.token))
}
@Test
fun unknownSubKindIsUnreadable() =
runTest {
// A mis-posted registry (vsk=8) at the bundle coordinate — the exact shape of the
// relayop.xyz link that hung — is present but not a vsk=6 bundle we can open.
val registry = coordinateEvent("aa".repeat(32), ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable")
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16)))
}
@Test
fun emptyFetchIsAbsent() {
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16)))
}
@Test
fun realRelayopBundleIsUnreadable() {
// The actual kind-33301 event behind the reported relayop.xyz/invite link (vsk=8), plus the
// 16-byte token from its #fragment. It fetches fine but can't be opened, so a compliant
// redeemer reports Unreadable instead of spinning forever.
val json =
"""{"content":"ApoDjyzcHUg2imEiqw6Gsfpc2O86r+CMtMor+jc8ZlgrYwlI6CCmX7qGGEQvEJ5537nINE9H09Ro8RtEghpYgwkhdPHS274RpklFmuyLMdcoC5u1EVhppu8BrlHZ0YBfw3GX1Ui0uwy3V/J+rvrYiLhdREmwlK39JAX8sZfzCUhVtDMCgLVy03dwdpTC1Kj/ZeZJTYhJ8qmaN2273jgBTno/bFLzJlYvbANss69Tg53mljcmdSyhMlZ8z1kuenm1zkrPO5yHvi//r25tXkXb580OCkWxTmEwFzo20ntMgFnVSwVRvLZelOZt++tMevqi2Z5asvDgG7RytHP/0vLxxPzmjH0No+nITsxcmDbEweoKvSSzoc/7DYzENmfmrLXgP2KU/eE6CpTcSNaedLVKbAu9XptdtV8ruZxHjVBh1wpOwXkETEdqqvbCiR4TCNWzqbmwRKJ+acvZLBxhXcpfqmRsolaATU4sZKLs4iu92YpMIuUDh2Pquu0Daiz/IGnVe7BPb7E/gSd9NBFIxds6Nk1DbP8XKMRtYmWdTforUPWZqdM4EOtt8AcNpALRmsbEF26Gyd6t4/81bQPh+7WhI97lR/KkdWtKxNjjJ4CoJLgceyHuwbxXnFR23IWhzvQpBY12MBeYOw9oizvEzEGhEqpUns6LkH2sUNRRXbneNNvVgCEk6BK7j6Dxi95mcGJDEtOW+coE1SjhnfrwjIsdJL7cUEyC5DHFKuvxUi0iw/1I6b3AfZV5+A1tssEE2dhDv8uw6B3/a5EfMURFDqSfmGw1btdPPJ3+yjo1yYu2BtbYa4U++GtaAJfmNPrsB9lm4YgXuwCCRSpI2+TR9H2ntWM2j3HVdXqOpg3kfX82o9KFndo2g+7vGrOAyfL1jcybluq7AxPEV6D5yBky82MjoMeS0vSM6ytYu+0jheWPwDVs/3iPTELHPeDXAZOaw76ISBvNsXcxHvFsSiZBguBr+ucZOUnazVRAYIsmm/WNcIJu+6tfbyupqFCo5wkus6lKN2RNYIH1SRIi163cdBDhTBOdZoI2WcDr+SSW2fHtZutk7fW5IkJvSuy5xlke+YW/u3uzvriAIRmVDtk/fKISKEnMj2G47JdGn6EiHf+2+XfUSuDiliJb62pPXWBupinbb9HEW0tuyPHYGACH0/GA/egr6KMgI6YSh+BWS8vniMRTkmouKCzL5Csvc+2txC9LrfodrMF2R3jFZ1nig0mYzTQ9HvhqA2Uc+YG06iZtRaU7KqH6fMZYzPbjrxVOliyXR2G6","created_at":1784122846,"id":"112701bc1541c10b92f5a105e2e1f1813e591936e20075ec6a53c8bb8d235d81","kind":33301,"pubkey":"7177ccb8e8786c152e4960765f03fbceb7419d36a26e693a6399319760e7fd30","sig":"80eb4b49d70d73d35c1026b9c06d0fab280787950b5df412ebe4cdd05fcacadb4d20419c4e732749ed2698186a321069b4329ebd93fe21d8594d7392bf6445e0","tags":[["d",""],["vsk","8"]]}"""
val event = Event.fromJson(json)
val token = "c0277c415fe2ecc901a22b2f23dca5bf".hexToByteArray()
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), token))
}
}