fix(concord): require consent for invite links, enforce expiry, fold the head

Three fixes to the Concord invite and moderation paths.

**Invite deep links redeemed with zero consent.** `ConcordInviteScreen`
called `joinConcordViaInvite` from a `LaunchedEffect` on open, and the
manifest registers `https://amethyst.social/invite/` as BROWSABLE. So a
link on any web page — or a QR code, or a push — silently caused a
connection to up to three ATTACKER-CHOSEN relay URLs decoded from the URL
fragment (disclosing the user's IP to a third party), a Guestbook JOIN
signed by the user's identity published to those relays, and a write to
their private community list. No tap, no preview.

The screen now opens in an awaiting-consent state and only joins from an
explicit Join button. The preview is built entirely from the link itself
— base64url and NIP-19 decoding, both pure in-memory — and touches the
network for nothing: no relay connection, no signing, no publishing. It
shows the relays it would contact so the user can see whom they'd be
talking to. The community name lives inside a bundle only those relays
can serve, so it is honestly reported as unknown until joining rather
than fetched.

**Invite expiry was decorative.** `ConcordInviteBundle.isExpired` had no
production callers at all — the only ones were in a test — so an expired
invite redeemed forever. Expiry is now enforced at `classify`, the choke
point every redeem path funnels through, with its own result and message
so the user knows to ask for a fresh link.

**Moderation read the wrong edition.** `ConcordModeration` used
`firstOrNull` over `controlEditions()`, which is in wrap-ARRIVAL order,
not the folded head. Once an entity had two or more editions the next one
chained off a stale predecessor, forking the chain at an already-used
version, and `EditionFold` then resolved the fork by `minByOrNull` on the
rumor id — a coin flip. Bans were masked by a down-only healing union;
UNBANS and role revocations were not, so they could silently fail to
apply. Both call sites now fold to the true head.

Regression tests assert the fold-head behaviour under two arrival orders
— a single order accidentally puts the head first and passes against the
buggy code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-19 18:59:50 -04:00
co-authored by Claude Opus 4.8
parent 153191e722
commit c8be65a02e
10 changed files with 368 additions and 53 deletions
@@ -2090,6 +2090,16 @@ class Account(
* bundle we can't open (e.g. minted by a newer client) must not strand the user
* on a spinner that retries forever.
*
* A bundle whose `expires_at` has passed is rejected with
* [ConcordInviteResult.Expired]. Expiry is resolved inside
* [ConcordActions.classifyInvite], so it is enforced on every redeem path rather
* than being a field nobody reads.
*
* **This must only ever be called from an explicit user action.** It contacts
* relay URLs carried in the link (chosen by whoever minted it) and publishes a
* Guestbook JOIN signed by this account, so calling it on deep-link arrival would
* leak the user's IP and enroll them without consent — see `ConcordInviteScreen`.
*
* If the resolved community is already in the joined list, this returns
* [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook
* JOIN, so reopening an old invite for a community you're already in simply takes
@@ -2112,6 +2122,7 @@ class Account(
val bundle =
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
is InviteBundleStatus.Live -> status.invite
is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired
InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked
InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible
InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable
@@ -47,6 +47,13 @@ sealed interface ConcordInviteResult {
*/
data object Revoked : ConcordInviteResult
/**
* The bundle opened fine, but its `expires_at` has passed. Retrying can't help —
* unlike [Revoked] the owner didn't retire the link, it simply timed out, so the
* user's next step is to ask for a fresh one.
*/
data object Expired : 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
@@ -88,40 +88,13 @@ fun ConcordInviteCard(
onClick = { nav.nav(Route.ConcordInvite(linkText)) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
ConcordInvitePreviewRow(
robotSeed = robotSeed,
title = title,
subtitle = stringRes(R.string.concord_invite_card_subtitle),
accountViewModel = accountViewModel,
autoPlayGif = autoPlayGif,
) {
RobohashFallbackAsyncImage(
robot = robotSeed,
model = null,
contentDescription = title,
modifier =
Modifier
.size(52.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.concord_invite_card_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
SymbolIcon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = stringRes(R.string.concord_invite_card_join),
@@ -131,3 +104,57 @@ fun ConcordInviteCard(
}
}
}
/**
* The avatar + title/subtitle row shared by [ConcordInviteCard] (in note content) and
* the deep-link consent screen, so both render an invite identically. Purely
* presentational — it performs no I/O, which is what lets the deep-link screen show a
* preview without contacting the link's (attacker-supplied) relays before the user
* consents.
*/
@Composable
fun ConcordInvitePreviewRow(
robotSeed: String,
title: String,
subtitle: String,
accountViewModel: AccountViewModel,
autoPlayGif: Boolean,
trailing: @Composable () -> Unit = {},
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RobohashFallbackAsyncImage(
robot = robotSeed,
model = null,
contentDescription = title,
modifier =
Modifier
.size(52.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
trailing()
}
}
@@ -23,9 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -38,13 +40,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.model.ConcordInviteResult
import com.vitorpamplona.amethyst.ui.components.ConcordInvitePreviewRow
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink
private sealed interface RedeemState {
/** Showing the local preview, waiting for the user to tap Join. Nothing has been sent. */
data object AwaitingConsent : RedeemState
data object Working : RedeemState
data class Done(
@@ -63,10 +73,25 @@ private sealed interface RedeemState {
}
/**
* Auto-redeems a Concord invite link (deep-link target for [Route.ConcordInvite]).
* On open it fetches + unlocks the bundle, joins the community, and forwards to its
* channel list. On failure it offers a retry, so a transient relay miss doesn't
* strand the user.
* Redeems a Concord invite link (deep-link target for [Route.ConcordInvite]).
*
* **This screen must never act before the user consents.** It is reachable from any
* `https://amethyst.social/invite/…` link on any web page, in any QR code, or in a
* push — i.e. from a URL the user may never have meant to open. Redeeming is a
* side-effecting act: it connects to up to three relay URLs *chosen by whoever minted
* the link* (disclosing the user's IP to them), publishes a Guestbook JOIN signed by
* the user's own identity to those relays, and writes the community into the user's
* private kind-13302 list. Doing that on arrival turned any link into a one-click
* deanonymize-and-enroll primitive, so the screen now opens on a local-only preview
* and only calls [com.vitorpamplona.amethyst.model.Account.joinConcordViaInvite] from
* the Join button.
*
* Everything shown before that tap comes from decoding the URL itself
* ([ConcordActions.parseInviteLink] — pure base64 + NIP-19, no I/O): the link's
* signer key and the bootstrap relays it would contact. The community's *name* lives
* inside the kind-33301 bundle, which only those relays can serve, so it is
* deliberately left unknown rather than fetched — fetching it is precisely the IP
* disclosure this screen exists to gate.
*/
@Composable
fun ConcordInviteScreen(
@@ -74,7 +99,19 @@ fun ConcordInviteScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
var state by remember(link) { mutableStateOf<RedeemState>(RedeemState.Working) }
// Local decode only: base64 fragment + NIP-19 naddr. No relay is contacted here.
val parsed = remember(link) { ConcordActions.parseInviteLink(link) }
var state by
remember(link) {
mutableStateOf<RedeemState>(
if (parsed == null) {
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
} else {
RedeemState.AwaitingConsent
},
)
}
LaunchedEffect(link, state) {
if (state is RedeemState.Working) {
@@ -82,13 +119,15 @@ fun ConcordInviteScreen(
when (val result = accountViewModel.account.joinConcordViaInvite(link)) {
is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId)
is ConcordInviteResult.InvalidLink ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_invalid, canRetry = false)
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
is ConcordInviteResult.Incompatible ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_incompatible, canRetry = false)
RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false)
is ConcordInviteResult.Revoked ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_revoked, canRetry = false)
RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false)
is ConcordInviteResult.Expired ->
RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false)
is ConcordInviteResult.NotReachable ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed, canRetry = true)
RedeemState.Failed(R.string.concord_invite_failed, canRetry = true)
}
}
}
@@ -96,8 +135,8 @@ fun ConcordInviteScreen(
LaunchedEffect(state) {
(state as? RedeemState.Done)?.let { done ->
// Replace this invite screen with the community, dropping it from the back stack. If it
// stayed, Back from the community would land on the auto-redeeming spinner, which would
// immediately re-join and forward here again — trapping the user in a Back→forward loop.
// stayed, Back from the community would land on a consent screen for a community the
// user has already joined — a dead end offering to re-do what just happened.
nav.popUpTo(Route.ConcordServer(done.communityId), Route.ConcordInvite::class)
}
}
@@ -108,10 +147,19 @@ fun ConcordInviteScreen(
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state) {
is RedeemState.AwaitingConsent ->
parsed?.let {
ConcordInviteConsent(
parsed = it,
accountViewModel = accountViewModel,
onJoin = { state = RedeemState.Working },
)
}
is RedeemState.Working -> {
CircularProgressIndicator()
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_redeeming_invite),
stringRes(R.string.concord_redeeming_invite),
modifier = Modifier.padding(top = 16.dp),
textAlign = TextAlign.Center,
)
@@ -129,7 +177,7 @@ fun ConcordInviteScreen(
onClick = { state = RedeemState.Working },
modifier = Modifier.padding(top = 16.dp),
) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.retry))
Text(stringRes(R.string.retry))
}
}
}
@@ -138,3 +186,55 @@ fun ConcordInviteScreen(
}
}
}
/**
* The pre-consent preview. Renders only what the URL itself decodes to — the link
* signer (used as the avatar seed) and the bootstrap relays the join would contact —
* plus a plain-language statement of what tapping Join will do. It performs **no**
* network I/O: the community name would require fetching the bundle from those very
* relays, which is the IP disclosure the consent gate exists to prevent, so it shows
* an explicit "name unknown until you join" instead.
*/
@Composable
private fun ConcordInviteConsent(
parsed: ParsedInviteLink,
accountViewModel: AccountViewModel,
onJoin: () -> Unit,
) {
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val relayList = remember(parsed) { parsed.fragment.relays.joinToString(", ") }
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
ConcordInvitePreviewRow(
robotSeed = parsed.linkSignerPubKey,
title = stringRes(R.string.concord_invite_card_subtitle),
subtitle = stringRes(R.string.concord_invite_preview_unknown_name),
accountViewModel = accountViewModel,
autoPlayGif = autoPlayGif,
)
}
Text(
stringRes(R.string.concord_invite_preview_explainer),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 20.dp),
)
if (relayList.isNotEmpty()) {
Text(
stringRes(R.string.concord_invite_preview_relays, relayList),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp),
)
}
Button(
onClick = onJoin,
modifier = Modifier.padding(top = 24.dp),
) {
Text(stringRes(R.string.concord_invite_card_join))
}
}
+4
View File
@@ -312,6 +312,10 @@
<string name="concord_invite_failed_invalid">This invite link is invalid or can\'t be opened with this account.</string>
<string name="concord_invite_failed_incompatible">This invite link can\'t be opened. It may be outdated or already replaced by a newer one, or created with a newer version of the app. Ask for a fresh invite link.</string>
<string name="concord_invite_failed_revoked">This invite link has been revoked and can no longer be used. Ask for a new one.</string>
<string name="concord_invite_failed_expired">This invite link has expired and can no longer be used. Ask for a fresh link.</string>
<string name="concord_invite_preview_unknown_name">Community name is only revealed after you join</string>
<string name="concord_invite_preview_explainer">Joining connects to this invite\'s relays, publishes a join announcement signed by your account, and adds the community to your list. Nothing is sent until you tap Join.</string>
<string name="concord_invite_preview_relays">Relays this invite will contact: %1$s</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>
@@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/** One decrypted, verified Concord channel message projected for display. */
data class ConcordChatMessage(
@@ -358,14 +359,16 @@ object ConcordActions {
/**
* 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.
* [InviteBundleStatus] (live / expired / revoked / unreadable / absent) per CORD-05
* §2, so a redeeming client honours a `vsk=9` revocation tombstone and an
* `expires_at` in the past, and reports why a link can't be opened instead of
* retrying blindly. [nowMs] is unix milliseconds.
*/
fun classifyInvite(
wraps: List<Event>,
token: ByteArray,
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token)
nowMs: Long = TimeUtils.nowMillis(),
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token, nowMs)
/** 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)
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
@@ -55,13 +56,31 @@ import kotlinx.serialization.builtins.serializer
* act under so the fold can verify the chain terminates at the owner.
*/
object ConcordModeration {
/**
* The current head of ([kind], [entityId]) within [current], or null if the entity
* has no editions yet.
*
* [current] arrives in **wrap-arrival order**, which is not chain order — so the
* first matching edition is whichever one a relay happened to deliver first, not
* the newest. Chaining off that stale edition would fork the chain at an
* already-used version, and [EditionFold] would then break the tie by
* `minByOrNull { rumorId }` — a coin flip that can silently drop the new edition
* (an unban or a role revocation quietly failing to apply). Fold the entity's
* chain instead, exactly as every reader does.
*/
private fun headOf(
current: List<ControlEdition>,
kind: ControlEntityKind,
entityId: ByteArray,
): ControlEdition? = EditionFold.foldEntity(current.filter { it.entityKind == kind && it.entityId.contentEquals(entityId) })
/** version/prevHash to chain onto the current head of ([kind], [entityId]), or genesis. */
private fun versioning(
current: List<ControlEdition>,
kind: ControlEntityKind,
entityId: ByteArray,
): Pair<Long, ByteArray?> {
val head = current.firstOrNull { it.entityKind == kind && it.entityId.contentEquals(entityId) }
val head = headOf(current, kind, entityId)
return if (head != null) (head.version + 1) to head.hash else 0L to null
}
@@ -184,7 +203,7 @@ object ConcordModeration {
communityId: ByteArray,
): Set<HexKey> {
val entityId = ConcordKeyDerivation.banlistCoordinate(communityId)
val head = current.firstOrNull { it.entityKind == ControlEntityKind.BANLIST && it.entityId.contentEquals(entityId) }
val head = headOf(current, ControlEntityKind.BANLIST, entityId)
return head?.let { ConcordJson.decodeBanlist(it.content) }?.mapTo(HashSet()) { it.lowercase() } ?: emptySet()
}
@@ -24,11 +24,15 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
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.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
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.assertFalse
import kotlin.test.assertTrue
@@ -36,6 +40,7 @@ class ConcordModerationTest {
private val owner = NostrSignerInternal(KeyPair())
private val admin = NostrSignerInternal(KeyPair())
private val troll = NostrSignerInternal(KeyPair())
private val stranger = NostrSignerInternal(KeyPair())
@Test
fun ownerDefinesRoleGrantsItAndBans() =
@@ -94,4 +99,79 @@ class ConcordModerationTest {
val afterForgery = ConcordCommunityState.fold(forgedEditions, community.ownerPubKey)
assertFalse(afterForgery.authority.effectivePermissions(troll.pubKey).has(ConcordPermissions.BAN))
}
/**
* Regression: [ConcordModeration] used to locate an entity's head with
* `current.firstOrNull { }`. `current` is the raw edition list in **wrap-arrival**
* order, not chain order, so once an entity had 2 editions the "head" was whichever
* one a relay delivered first a stale one. The next edition then chained off it,
* forking the chain at an already-used version, and [EditionFold] resolved the fork by
* `minByOrNull { rumorId }` a coin flip that could silently drop the change. Bans are
* masked by the down-only healing union, but an unban is not, so an unban simply
* failed to apply.
*
* Here the banlist has two editions (v0 bans [troll], v1 also bans [stranger]) before
* the unban. The unban must chain off v1 the folded head at v2, not off v0.
*/
@Test
fun thirdBanlistEditionChainsOffTheFoldedHeadNotTheFirstArrival() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val cp = community.controlPlane
val communityId = community.communityId
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
// v0: ban the troll. v1: ban the stranger too (chains onto v0).
editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 2L)), cp)
editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, stranger.pubKey, editions, createdAt = 3L)), cp)
val banlistSoFar = editions.filter { it.entityKind == ControlEntityKind.BANLIST }
assertEquals(2, banlistSoFar.size)
val head = EditionFold.foldEntity(banlistSoFar)!!
assertEquals(1L, head.version)
// The stale v0 sorts first in arrival order — exactly what the old firstOrNull picked up.
assertEquals(0L, banlistSoFar.first().version)
// Now unban the troll. The head must be found regardless of arrival order — in
// particular in the natural order, where the stale v0 comes first and is exactly
// what the old firstOrNull latched onto.
for (arrival in listOf(editions.toList(), editions.reversed())) {
val unbanWrap = ConcordModeration.unban(owner, cp, communityId, troll.pubKey, arrival, createdAt = 4L)
val unban = ConcordActions.controlEditions(listOf(unbanWrap), cp).single()
// Chains onto the folded head (v1), not the first-arrival v0.
assertEquals(2L, unban.version)
assertEquals(head.hashHex, unban.prevHash!!.toHexKey())
// And the resulting state is the one the moderator asked for: troll freed, stranger still banned.
val state = ConcordCommunityState.fold(editions + unban, community.ownerPubKey)
assertFalse(state.authority.isBanned(troll.pubKey))
assertTrue(state.authority.isBanned(stranger.pubKey))
}
}
/** The same stale-head trap on a versioned entity: a third role edition must be v2. */
@Test
fun thirdRoleEditionChainsOffTheFoldedHead() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val cp = community.controlPlane
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
val roleId = ByteArray(32) { (it + 1).toByte() }
fun role(name: String) = RoleEntity(name = name, position = 1, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire())
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Mod"), editions, createdAt = 2L)), cp)
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Admin"), editions, createdAt = 3L)), cp)
for (arrival in listOf(editions.toList(), editions.reversed())) {
val third = ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Owner"), arrival, createdAt = 4L)), cp).single()
assertEquals(2L, third.version)
val state = ConcordCommunityState.fold(editions + third, community.ownerPubKey)
assertEquals("Owner", state.roles[roleId.toHexKey()]?.name)
}
}
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip44Encryption.Nip44
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* What the events fetched at an invite's addressable coordinate `(33301,
@@ -50,6 +51,16 @@ sealed interface InviteBundleStatus {
/** The newest event at the coordinate is a `vsk=9` revocation tombstone — the link was retired. */
data object Revoked : InviteBundleStatus
/**
* A `vsk=6` bundle that opened and validated, but whose `expires_at` is in the past.
* The [invite] is still carried so a preview can render what the link *would* have
* opened; joining must be refused (CORD-05 an expiry that nobody enforces is
* decorative).
*/
data class Expired(
val invite: CommunityInvite,
) : 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
@@ -120,15 +131,25 @@ object ConcordInviteBundle {
* 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].
*
* An opened bundle whose `expires_at` has passed (compared against [nowMs], unix
* milliseconds) resolves to [InviteBundleStatus.Expired] rather than
* [InviteBundleStatus.Live], so the expiry is actually enforced at the one place
* every redeeming client already funnels through.
*/
fun classify(
wraps: List<Event>,
token: ByteArray,
nowMs: Long = TimeUtils.nowMillis(),
): 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
return when {
invite == null -> InviteBundleStatus.Unreadable
isExpired(invite, nowMs) -> InviteBundleStatus.Expired(invite)
else -> InviteBundleStatus.Live(invite)
}
}
/**
@@ -104,6 +104,49 @@ class ConcordInviteClassifyTest {
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16)))
}
/**
* Regression: `expires_at` used to be decorative [ConcordInviteBundle.isExpired] had no
* production caller, so an expired link redeemed forever. Enforcement lives in [classify],
* which every redeeming path (Account.joinConcordViaInvite) funnels through.
*/
@Test
fun expiredBundleDoesNotResolveLive() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
val expiresAtMs = 2_000_000L
val invite =
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",
expiresAt = expiresAtMs,
)
val minted = ConcordInviteBundle.mintLink("https://vector.chat", invite, createdAt = 1L, relays = listOf("wss://relay.example"))
val wraps = listOf(minted.bundleEvent)
// Before the expiry the very same bundle still opens…
val live = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs - 1)
assertTrue(live is InviteBundleStatus.Live)
// …and after it, the join path must refuse it (not Live) while the preview data survives.
val expired = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs + 1)
assertTrue(expired is InviteBundleStatus.Expired)
assertEquals(community.communityIdHex, expired.invite.communityId)
}
/** No `expires_at` means "never expires" — it must not be read as "expired at epoch 0". */
@Test
fun bundleWithoutExpiryNeverExpires() =
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"))
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live)
}
@Test
fun emptyFetchIsAbsent() {
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16)))