From c8be65a02ea614073eb80403e318ab2c2804ec5d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 19 Jul 2026 18:59:50 -0400 Subject: [PATCH] fix(concord): require consent for invite links, enforce expiry, fold the head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../vitorpamplona/amethyst/model/Account.kt | 11 ++ .../amethyst/model/ConcordInviteResult.kt | 7 + .../ui/components/ConcordInviteCard.kt | 93 ++++++++----- .../concord/ConcordInviteScreen.kt | 126 ++++++++++++++++-- amethyst/src/main/res/values/strings.xml | 4 + .../commons/actions/ConcordActions.kt | 11 +- .../commons/actions/ConcordModeration.kt | 23 +++- .../commons/actions/ConcordModerationTest.kt | 80 +++++++++++ .../cord05Invites/ConcordInviteBundle.kt | 23 +++- .../ConcordInviteClassifyTest.kt | 43 ++++++ 10 files changed, 368 insertions(+), 53 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9ea8909821..254d094926 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 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 6c9cc50222..6b4d2599cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt index c577c5d412..6e08f8b3e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt @@ -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() + } +} 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 42748cc684..d66aef1608 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 @@ -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.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( + 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)) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 11cb80480c..0ef4fe5dec 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -312,6 +312,10 @@ 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 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. + Relays this invite will contact: %1$s Concord Channels You haven\'t joined any Concord Channels yet. Create one, or open an invite link. No channels yet. 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 742c689a0b..ebbe1ecbbb 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 @@ -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, 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) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index 31c25887cd..ef0210be9c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -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, + 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, kind: ControlEntityKind, entityId: ByteArray, ): Pair { - 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 { 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() } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt index 1b91d1c85a..05e6c5ad15 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt @@ -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) + } + } } 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 6feab287b2..bf3e3ee336 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 @@ -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, 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) + } } /** 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 00d941ad25..95329c3b98 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 @@ -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)))