mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +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
+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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user