mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
co-authored by
Claude Opus 4.8
parent
153191e722
commit
c8be65a02e
@@ -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
|
||||
|
||||
+60
-33
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+113
-13
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+7
-4
@@ -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)
|
||||
|
||||
+21
-2
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+43
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user