diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0e857e8564..c68b4cfb12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import com.vitorpamplona.amethyst.commons.model.preferences.BuzzAttestationStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzChannelStarStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore +import com.vitorpamplona.amethyst.commons.model.preferences.ConcordDirectInviteDeclineStore import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore @@ -1070,6 +1071,8 @@ class AppModules( // Eager like the rest, so a held NIP-OA attestation is loaded before this account's // first Buzz-relay AUTH rather than after it. BuzzAttestationStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzAttestation) + // Concord Direct Invites the user declined (CORD-05 §6) stay declined across restarts. + ConcordDirectInviteDeclineStore(sharedSettingsStore, account.scope, account.pubKey, account.concord.directInviteInbox) }, ) 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 40ff004467..86f7a9d918 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 @@ -33,14 +33,18 @@ import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend import com.vitorpamplona.quartz.concord.cord05Invites.ConcordStrandedRecovery import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.OpenedDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding @@ -50,11 +54,15 @@ import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -316,8 +324,15 @@ object ConcordActions { */ fun bundlesFilter(linkSignerPubKeyHexes: List): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = linkSignerPubKeyHexes) - /** Pending direct invites addressed to the given member (indexed by k=3313). */ - fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString()))) + /** + * Pending direct invites addressed to the given member (indexed by k=3313, CORD-05 §6). [since] + * should come from [ConcordDirectInvite.inboxSince]: wraps are backdated up to two days, so a + * cursor at the newest wrap seen would miss invites published after it. + */ + fun directInvitesFilter( + memberPubKeyHex: HexKey, + since: Long? = null, + ): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString())), since = since) // ---- community lifecycle -------------------------------------------------- @@ -583,6 +598,64 @@ object ConcordActions { label = label, ) + /** + * The §1 bundle a Direct Invite hands [recipient] for the community [entry] holds (CORD-05 §6): + * the current base, epoch and `control_pk`, the relays, a name/icon preview, the optional + * [expiresAtMs] (unix ms) and [creator] attribution — and exactly the Private Channel keys the + * recipient's Roles entitle them to in [authority] ([ConcordInviteVend.vendableChannels], Armada's + * `VendAudience` "member" rule). A key the recipient isn't entitled to is never whispered, even + * though nothing on the wire could stop it. + */ + fun directInviteFor( + entry: ConcordCommunityListEntry, + authority: AuthorityResolver, + recipient: HexKey, + creator: HexKey, + expiresAtMs: Long? = null, + name: String = entry.name, + icon: ImagePointer? = null, + ): CommunityInvite = + CommunityInvite( + communityId = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + communityRoot = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk, + channels = ConcordInviteVend.toInviteChannels(ConcordInviteVend.vendableChannels(entry.privateChannels, authority, recipient)), + relays = entry.relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), + name = name.ifBlank { entry.name }, + icon = icon, + expiresAt = expiresAtMs, + creatorNpub = creator, + ) + + /** Giftwraps [invite] to [recipient] as a Direct Invite (see [ConcordDirectInvite.build]). */ + suspend fun buildDirectInvite( + senderSigner: NostrSigner, + recipient: HexKey, + invite: CommunityInvite, + createdAt: Long = TimeUtils.now(), + ): GiftWrapEvent = ConcordDirectInvite.build(senderSigner, recipient, invite, createdAt) + + /** Opens + validates a Direct Invite wrap addressed to [recipientSigner] (see [ConcordDirectInvite.open]). */ + suspend fun openDirectInvite( + wrap: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? = ConcordDirectInvite.open(wrap, recipientSigner) + + /** + * Where a Direct Invite reaches a member, and where that member scans for one (CORD-05 §6): + * their kind-10050 DM relays, else their NIP-65 read relays, else the stock Concord set every + * client ships (Armada `inviteDeliveryRelays`). Send and scan share this so both sides meet. The + * stock set is fallback-only: a curated private inbox is never also fanned out to public relays. + */ + fun directInviteDeliveryRelays(lists: RecipientRelayFetcher.Lists?): Set { + val inbox = lists?.dmInboxOrFallback().orEmpty() + if (inbox.isNotEmpty()) return inbox.toSet() + return InviteRelayDictionary.STOCK.mapNotNullTo(LinkedHashSet()) { RelayUrlNormalizer.normalizeOrNull(it) } + } + /** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */ fun mintInviteLink( base: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 0ebe09d709..e844c8b791 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -24,12 +24,16 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView +import com.vitorpamplona.amethyst.commons.model.concord.DirectInviteAcceptPlan import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute import com.vitorpamplona.amethyst.commons.util.ConcurrentSet import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode @@ -65,6 +69,7 @@ import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -90,8 +95,11 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn /** Name of the default Concord community Admin role minted by "Make admin". */ private const val CONCORD_ADMIN_ROLE = "Admin" @@ -559,6 +567,38 @@ class AccountConcordActions( InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable } + return joinValidatedConcordInvite( + bundle = bundle, + servedBy = relays, + // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a + // Refounding that leaves us out of the recipient set is recoverable later. See + // recoverStrandedConcordCommunities(). + inviteRef = ConcordActions.bareInviteRef(url), + // Invite attribution (CORD-05 §1): the joiner echoes the link's creator + label in their + // Guestbook Join, which is what makes per-link usage counters possible. + inviteCreator = bundle.creatorNpub, + inviteLabel = bundle.label, + ) + } + + /** + * The join half shared by every redeem path (link [joinConcordViaInvite], Direct Invite + * [acceptConcordDirectInvite]): [bundle] is already opened, bounded and owner-proof validated, + * and not expired. An already-held community only moves forward through a stranded rejoin (a + * Refounding left us behind and the user re-accepted); otherwise it refuses a community whose + * roster bans us (fails closed on an unreadable Control Plane, fetched over [servedBy] ∪ the + * bundle's relays), then stores the secret-bearing entry and announces the Guestbook Join with + * [inviteCreator]/[inviteLabel] attribution. + */ + private suspend fun joinValidatedConcordInvite( + bundle: CommunityInvite, + servedBy: Set, + inviteRef: String?, + inviteCreator: HexKey?, + inviteLabel: String?, + ): ConcordInviteResult { + val relays = servedBy + // Already a member? Just take the user to the community. Re-following and re-announcing a // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an // old invite is reopened, so short-circuit to Joined — the screen forwards to the community @@ -620,15 +660,14 @@ class AccountConcordActions( return ConcordInviteResult.Banned } - // Invite attribution (CORD-05 §1): the joiner echoes the link's creator + label in their - // Guestbook Join, which is what makes per-link usage counters possible. - val inviteCreator = bundle.creatorNpub?.lowercase()?.takeIf { HEX64.matches(it) } - val inviteLabel = bundle.label?.takeIf { inviteCreator != null && it.isNotBlank() } + // Invite attribution (CORD-05 §1), echoed in the Guestbook Join; a label only rides with a creator. + val creator = inviteCreator?.lowercase()?.takeIf { HEX64.matches(it) } + val label = inviteLabel?.takeIf { creator != null && it.isNotBlank() } if (rejoined != null) { if (!adoptedConcordRotations.add("${rejoined.id}:${rejoined.rootEpoch}")) return ConcordInviteResult.Joined(bundle.communityId) Log.i("Concord") { "Stranded rejoin by explicit invite: ${rejoined.id} -> epoch ${rejoined.rootEpoch}" } - joinConcordCommunity(rejoined, inviteCreator, inviteLabel) + joinConcordCommunity(rejoined, creator, label) _strandedConcordCommunities.value -= rejoined.id return ConcordInviteResult.Joined(bundle.communityId) } @@ -649,15 +688,175 @@ class AccountConcordActions( relays = bundle.relays, name = bundle.name, addedAt = TimeUtils.nowMillis(), - // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a - // Refounding that leaves us out of the recipient set is recoverable later. See - // recoverStrandedConcordCommunities(). - inviteRef = ConcordActions.bareInviteRef(url), + // Anchor for stranded recovery (null for a Direct Invite, which has no link). + inviteRef = inviteRef, ) - joinConcordCommunity(entry, inviteCreator, inviteLabel) + joinConcordCommunity(entry, creator, label) return ConcordInviteResult.Joined(bundle.communityId) } + // ---- CORD-05 §6 Direct Invites --------------------------------------------- + + /** + * The Direct Invite inbox: wraps from the dedicated sweep ([refreshConcordDirectInvites]) and + * from the NIP-17 giftwrap pipeline land here, parked until the user accepts or declines. + */ + val directInviteInbox = ConcordDirectInviteInbox(account.signer) + + /** + * The parked Direct Invites a UI should show, newest first: invites for communities we don't + * hold, plus catch-ups for ones we do ([ConcordDirectInviteInbox.visible]). + */ + val pendingConcordDirectInvites: StateFlow> = + combine(directInviteInbox.pending, account.concordChannelList.liveCommunities) { pending, joined -> + ConcordDirectInviteInbox.visible(pending.values, joined) + }.stateIn(account.scope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** + * Where this account scans for Direct Invites — where senders deliver them (CORD-05 §6): our DM + * inbox relays (kind 10050, plus the NIP-65 read and private/local relays the DM feed already + * reads), else the stock Concord set. + */ + private fun concordDirectInviteScanRelays(): Set = + account.dmRelays.flow.value.ifEmpty { + ConcordActions.directInviteDeliveryRelays(null) + } + + /** + * Sweeps our inbox relays for Direct Invite wraps + * (`{"kinds":[1059],"#p":[me],"#k":["3313"]}` since the inbox cursor, rewound by NIP-59's backdate + * window) and offers each to the inbox. Returns how many new invites were parked. Read-only: it + * decrypts, it never joins or contacts a community's relays. + */ + suspend fun refreshConcordDirectInvites(): Int { + val relays = concordDirectInviteScanRelays() + if (relays.isEmpty()) return 0 + val before = directInviteInbox.pending.value.keys + val filter = ConcordActions.directInvitesFilter(account.signer.pubKey, directInviteInbox.since()) + val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }) + wraps.distinctBy { it.id }.forEach { directInviteInbox.offer(it) } + return (directInviteInbox.pending.value.keys - before).size + } + + /** + * The recipient's giftwrap inbox (CORD-05 §6): their kind-10050 DM relays, else NIP-65 read + * relays — from the cache when we have their lists, fetched otherwise — else the stock set. + */ + private suspend fun concordDirectInviteDeliveryRelays(recipient: HexKey): Set { + val user = account.cache.getOrCreateUser(recipient) + val dmInbox = user.dmInboxRelayList()?.relays().orEmpty() + val cached = + if (dmInbox.isNotEmpty() || user.authorRelayList() != null) { + RecipientRelayFetcher.Lists(dmInbox = dmInbox, keyPackage = emptyList(), nip65 = user.authorRelayList()) + } else { + null + } + val lists = + cached ?: run { + val seed = DefaultDmIndexerRelays.RELAYS.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value + RecipientRelayFetcher.fetchRelayLists(account.client, recipient, seed) + } + return ConcordActions.directInviteDeliveryRelays(lists) + } + + /** + * Hands the keys of [communityId] straight to [recipientPubKey] as a Direct Invite (CORD-05 §6): + * the §1 bundle — carrying only the Private Channel keys the recipient's Roles entitle them to — + * sealed by our real key inside an ephemeral, `k`-tagged giftwrap, published to the recipient's + * inbox relays. It appears in no Registry and never flips the community Public; it cannot be + * revoked once it lands. [expiresAtMs] (unix ms) bounds its shelf life. + * + * No community permission gates it — none could (CORD-05 §6) — but a banned member is refused, + * like minting, and so is a banned recipient, whom the join would refuse anyway. + */ + suspend fun sendConcordDirectInvite( + communityId: String, + recipientPubKey: HexKey, + expiresAtMs: Long? = null, + ): ConcordDirectInviteSendResult { + if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE + val recipient = recipientPubKey.lowercase() + if (!HEX64.matches(recipient)) return ConcordDirectInviteSendResult.INVALID_RECIPIENT + val entry = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } ?: return ConcordDirectInviteSendResult.NOT_MEMBER + val state = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED + if (state.dissolved) return ConcordDirectInviteSendResult.NOT_MEMBER + if (state.authority.isBanned(account.signer.pubKey)) return ConcordDirectInviteSendResult.NOT_MEMBER + if (state.authority.isBanned(recipient)) return ConcordDirectInviteSendResult.RECIPIENT_BANNED + + val invite = + ConcordActions.directInviteFor( + entry = entry, + authority = state.authority, + recipient = recipient, + creator = account.signer.pubKey, + expiresAtMs = expiresAtMs, + name = state.metadata?.name ?: entry.name, + icon = state.metadata?.icon, + ) + val wrap = ConcordActions.buildDirectInvite(account.signer, recipient, invite) + val relays = concordDirectInviteDeliveryRelays(recipient) + if (relays.isEmpty()) return ConcordDirectInviteSendResult.NOT_DELIVERED + val delivered = + runCatching { account.client.publishAndConfirm(wrap, relays) } + .onFailure { Log.w("Concord", "direct invite publish failed for $communityId", it) } + .getOrDefault(false) + return if (delivered) ConcordDirectInviteSendResult.SENT else ConcordDirectInviteSendResult.NOT_DELIVERED + } + + /** + * Accepts the parked Direct Invite [wrapId] (CORD-05 §6) through the same join path as a link: + * refused once `expires_at` has passed, refused when the roster bans us, and — for a community + * we already hold — only a catch-up adopting newly granted Private Channel keys on the same base. + * The Guestbook Join is attributed to the seal-verified sender. **Only from an explicit user + * action**: this is the first moment anything contacts the community's relays. + */ + suspend fun acceptConcordDirectInvite(wrapId: HexKey): ConcordInviteResult { + if (!account.isWriteable()) return ConcordInviteResult.InvalidLink + val opened = directInviteInbox.get(wrapId) ?: return ConcordInviteResult.InvalidLink + val bundle = opened.invite + val held = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id.equals(bundle.communityId, ignoreCase = true) } + val heldState = + held?.let { + account.concordSessions + .sessionFor(it.id) + ?.state + ?.value + } + val result = + when (val plan = ConcordDirectInviteInbox.acceptPlan(opened, held, heldState, account.signer.pubKey)) { + DirectInviteAcceptPlan.Expired -> ConcordInviteResult.Expired + DirectInviteAcceptPlan.Banned -> ConcordInviteResult.Banned + // No folded roster yet: whether it bans us is unknown, so the invite waits. + DirectInviteAcceptPlan.RosterNotLoaded -> ConcordInviteResult.NotReachable + DirectInviteAcceptPlan.NothingNew -> ConcordInviteResult.Joined(bundle.communityId) + // Keys only, on the held base: no second Guestbook Join. + is DirectInviteAcceptPlan.CatchUp -> + if (persistConcordEntry(plan.entry)) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable + DirectInviteAcceptPlan.Join -> + joinValidatedConcordInvite( + bundle = bundle, + servedBy = emptySet(), + inviteRef = null, + // Attributed to the seal-verified sender (Armada), never the bundle's claim. + inviteCreator = opened.sender, + inviteLabel = bundle.label, + ) + } + if (result is ConcordInviteResult.Joined) directInviteInbox.resolve(opened.wrapId) + return result + } + + /** Declines the parked Direct Invite [wrapId]: its keys are discarded and it never resurfaces. */ + fun declineConcordDirectInvite(wrapId: HexKey): Boolean = directInviteInbox.decline(wrapId) + /** * Post [text] to a Concord channel: derive the channel plane key, build an * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt new file mode 100644 index 0000000000..d82eb09e5c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model + +/** The outcome of sending a Concord Direct Invite (CORD-05 §6), so the UI can say why it failed. */ +enum class ConcordDirectInviteSendResult { + /** At least one of the recipient's inbox relays accepted the wrap. */ + SENT, + + /** This account can't sign (read-only key). */ + NOT_WRITEABLE, + + /** The recipient isn't a valid 32-byte pubkey. */ + INVALID_RECIPIENT, + + /** We don't hold this community, it was dissolved, or its roster bans us. */ + NOT_MEMBER, + + /** The community's Control Plane hasn't folded yet, so which keys the recipient may receive is unknown. */ + ROSTER_NOT_LOADED, + + /** The community's roster bans the recipient; their join would be refused anyway. */ + RECIPIENT_BANNED, + + /** No inbox relay accepted the wrap. */ + NOT_DELIVERED, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt index ccea35dc44..5ca3c6f8f7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.chatMessageMarksRoomAsRead import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor @@ -536,6 +537,17 @@ class SealEventHandler( ) { val innerRumor = event.unsealOrNull(account.signer) ?: return + // A Concord Direct Invite (CORD-05 §6) is a standard NIP-59 giftwrap, so the DM inbox sees + // it too — tagged `k=3313` or not. It is not a DM: its rumor carries a community's keys. Hand + // the seal to the Concord invite inbox, which re-opens it with the NIP-59 anti-spoofing check + // the generic unseal skips and parks it for the user, and keep the rumor out of the cache and + // every chat feed. Must run before the seal's content is stripped below. + if (innerRumor.kind == ConcordDirectInvite.KIND) { + account.concord.directInviteInbox.offerSeal(publicNote.event ?: event, event) + eventNote.event = event.copyNoContent() + return + } + eventNote.event = event.copyNoContent() cache.justConsume(innerRumor, null, true) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt new file mode 100644 index 0000000000..10e74ca258 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend +import com.vitorpamplona.quartz.concord.cord05Invites.OpenedDirectInvite +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.concurrent.Volatile + +/** + * One parked Direct Invite as the UI renders it (CORD-05 §6): who sent it (seal-verified), what it + * opens (name/icon preview from the bundle), whether its `expires_at` has passed, and whether it + * is a [catchUp] — a Private Channel key for a community this account already holds on the same + * base, which accepting merges in without moving the base or announcing a new Join. + */ +@Immutable +class ConcordDirectInviteView( + val opened: OpenedDirectInvite, + val catchUp: Boolean, + val expired: Boolean, +) { + val wrapId: HexKey get() = opened.wrapId + val sender: HexKey get() = opened.sender + val invite: CommunityInvite get() = opened.invite + val communityId: HexKey get() = opened.invite.communityId + val name: String get() = opened.invite.name + val icon: ImagePointer? get() = opened.invite.icon + + /** Names of the Private Channels the bundle carries (what a catch-up would add). */ + val channelNames: List get() = + opened.invite.channels + .filter { it.key.isNotBlank() } + .map { it.name } +} + +/** What accepting a Direct Invite does; see [ConcordDirectInviteInbox.acceptPlan]. */ +sealed interface DirectInviteAcceptPlan { + /** `expires_at` has passed: the preview renders, joining refuses. */ + data object Expired : DirectInviteAcceptPlan + + /** A community we don't hold: run the shared join path. */ + data object Join : DirectInviteAcceptPlan + + /** A held community: store [entry] — the held one plus the newly granted Private Channel keys. */ + class CatchUp( + val entry: ConcordCommunityListEntry, + ) : DirectInviteAcceptPlan + + /** A held community the bundle adds nothing to (or can't: a different base, or dissolved). */ + data object NothingNew : DirectInviteAcceptPlan + + /** The held community's roster bans us. */ + data object Banned : DirectInviteAcceptPlan + + /** The held community's roster isn't folded yet, so the ban verdict is unknown: wait. */ + data object RosterNotLoaded : DirectInviteAcceptPlan +} + +/** + * The Direct Invite inbox (CORD-05 §6) — headless, shared by the app and `amy`. + * + * Wraps arrive from anywhere — a `{"kinds":[1059],"#p":[me],"#k":["3313"]}` sweep + * ([com.vitorpamplona.amethyst.commons.actions.ConcordActions.directInvitesFilter]), or the general + * NIP-17 giftwrap pipeline, which honours an untagged invite all the same — and are [offer]ed here. + * The inbox opens each wrap once (two NIP-44 decrypts), dedupes by wrap id, drops a wrap whose NIP-40 + * `expiration` has passed, validates the bundle exactly like a fetched one, and parks it in + * [pending]. **Nothing** else happens: no relay connection, no icon fetch, no Join, until the user + * accepts (the caller's join path) or [decline]s. + * + * Declined wrap ids are remembered ([declined], restorable via [restoreDeclined]) so a re-delivered + * wrap never resurfaces. [newestWrapCreatedAt] is the sweep cursor; query from [since], which + * rewinds it by NIP-59's two-day backdate window. + */ +class ConcordDirectInviteInbox( + private val signer: NostrSigner, +) { + private val mutex = Mutex() + + /** Wrap ids already handled this session (opened, refused, or expired), oldest first. */ + private val seen = LinkedHashSet() + + private val _pending = MutableStateFlow>(emptyMap()) + + /** Parked invites by wrap id, as opened. See [visible] for what a UI should show. */ + val pending: StateFlow> = _pending.asStateFlow() + + private val _declined = MutableStateFlow>(emptySet()) + + /** Wrap ids the user declined; persisted by the front end so they stay declined across restarts. */ + val declined: StateFlow> = _declined.asStateFlow() + + /** The newest wrap `created_at` offered so far (the sweep cursor), or null on a cold inbox. */ + @Volatile + var newestWrapCreatedAt: Long? = null + private set + + /** The `since` for the next sweep: the cursor rewound by the backdate window (null = everything). */ + fun since(): Long? = ConcordDirectInvite.inboxSince(newestWrapCreatedAt) + + /** Replaces the declined set — used to restore it from disk at startup. Drops any pending one. */ + fun restoreDeclined(wrapIds: Set) { + _declined.value = wrapIds + _pending.update { current -> current.filterKeys { it !in wrapIds } } + } + + /** + * Considers one kind-1059 [wrap] addressed to us. Returns the parked invite (new or already + * pending), or null when it isn't one: not a direct invite for us, a forgery, an invalid + * bundle, an expired handoff, or a wrap the user already declined. Never throws. + */ + suspend fun offer( + wrap: Event, + nowSecs: Long = TimeUtils.now(), + ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.open(wrap, signer) } + + /** + * [offer] for a pipeline that already peeled [wrap] down to its kind-13 [seal] (the NIP-17 + * giftwrap inbox). [wrap] only lends its id, `created_at` and tags, so a content-stripped copy + * is fine; the seal is re-opened with the anti-spoofing check the generic unseal skips. + */ + suspend fun offerSeal( + wrap: Event, + seal: Event, + nowSecs: Long = TimeUtils.now(), + ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openSeal(wrap.id, seal, signer) } + + private suspend fun admit( + wrap: Event, + nowSecs: Long, + open: suspend () -> OpenedDirectInvite?, + ): OpenedDirectInvite? { + if (wrap.kind != GiftWrapEvent.KIND) return null + mutex.withLock { + val newest = newestWrapCreatedAt + if (newest == null || wrap.createdAt > newest) newestWrapCreatedAt = wrap.createdAt + _pending.value[wrap.id]?.let { return it } + if (wrap.id in _declined.value || wrap.id in seen) return null + remember(wrap.id) + } + // An expired handoff is never decrypted or surfaced (NIP-40 on the wrap mirrors expires_at). + if (ConcordDirectInvite.isWrapExpired(wrap, nowSecs)) return null + val opened = open() ?: return null + mutex.withLock { + if (wrap.id in _declined.value) return null + _pending.update { it + (wrap.id to opened) } + } + return opened + } + + /** The parked invite behind [wrapId], if any. */ + fun get(wrapId: HexKey): OpenedDirectInvite? = _pending.value[wrapId.lowercase()] ?: _pending.value[wrapId] + + /** Discards [wrapId] for good (CORD-05 §6 "declining means discarding them"). False if not pending. */ + fun decline(wrapId: HexKey): Boolean { + val id = get(wrapId)?.wrapId ?: return false + _pending.update { it - id } + _declined.update { it + id } + return true + } + + /** Drops [wrapId] after it was accepted; this session will not re-park it. */ + fun resolve(wrapId: HexKey) { + _pending.update { it - wrapId } + } + + private fun remember(wrapId: HexKey) { + if (seen.size >= SEEN_CAP) { + val drop = seen.take(SEEN_CAP / 2) + seen.removeAll(drop.toSet()) + } + seen.add(wrapId) + } + + companion object { + /** Cap on remembered wrap ids; the oldest half is shed past it (a sweep re-dedupes deeper). */ + const val SEEN_CAP = 4096 + + /** + * What accepting [opened] should do (CORD-05 §6), given the community entry this account + * already [held] (if any) and its folded [heldState]: + * - past `expires_at` → [DirectInviteAcceptPlan.Expired] ("`expires_at` refuses a late join"); + * - not held → [DirectInviteAcceptPlan.Join] (the shared join path, which still ban-gates + * against the community's own Control Plane); + * - held on the SAME base with new Private Channel keys → [DirectInviteAcceptPlan.CatchUp], + * the held entry with only those keys merged in — never moving the base (Armada + * `catchUpChannelIds`) — unless the held roster bans [me]; refused while the roster isn't + * folded ([DirectInviteAcceptPlan.RosterNotLoaded]); + * - held otherwise (nothing new, a different base, dissolved) → [DirectInviteAcceptPlan.NothingNew]. + */ + fun acceptPlan( + opened: OpenedDirectInvite, + held: ConcordCommunityListEntry?, + heldState: ConcordCommunityState?, + me: HexKey, + nowMs: Long = TimeUtils.nowMillis(), + ): DirectInviteAcceptPlan { + if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired + if (held == null) return DirectInviteAcceptPlan.Join + val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite) ?: return DirectInviteAcceptPlan.NothingNew + if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded + // Death wins every race (CORD-02 §9): a dissolved community takes no new keys. + if (heldState.dissolved) return DirectInviteAcceptPlan.NothingNew + if (heldState.authority.isBanned(me)) return DirectInviteAcceptPlan.Banned + return DirectInviteAcceptPlan.CatchUp(adopted) + } + + /** + * What a UI shows out of [pending], given the communities this account already holds + * ([joined]): newest first, with + * - an invite for a community already held on the SAME base that carries a Private Channel + * key it lacks kept as a [ConcordDirectInviteView.catchUp]; + * - any other invite for a held community (nothing new, or a different base — which may + * never move the held one) hidden; + * - one invite per community (newest `sentAt`, ties by wrap id), catch-ups keyed by their + * channel set too since each may vend a key no other wrap carries (Armada + * `dedupeParkedInvites`). + */ + fun visible( + pending: Collection, + joined: List, + nowMs: Long = TimeUtils.nowMillis(), + ): List { + val heldById = joined.associateBy { it.id.lowercase() } + val byKey = LinkedHashMap() + for (opened in pending) { + val communityId = opened.invite.communityId.lowercase() + val held = heldById[communityId] + val newChannels = ConcordInviteVend.catchUpChannelIds(held, opened.invite) + if (held != null && newChannels.isEmpty()) continue + val catchUp = held != null + val key = if (catchUp) communityId + "|" + newChannels.sorted().joinToString(",") else communityId + val view = ConcordDirectInviteView(opened, catchUp, opened.isExpired(nowMs)) + val existing = byKey[key] + if (existing == null || + opened.sentAt > existing.opened.sentAt || + (opened.sentAt == existing.opened.sentAt && opened.wrapId < existing.opened.wrapId) + ) { + byKey[key] = view + } + } + return byKey.values.sortedWith(compareByDescending { it.opened.sentAt }.thenBy { it.wrapId }) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt new file mode 100644 index 0000000000..0fecf0b490 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Per-account persistence for the Concord Direct Invites the user declined (CORD-05 §6), so a + * declined invite's wrap — which relays keep re-delivering until its NIP-40 expiration — never + * resurfaces after a restart. Mirrors [BuzzChannelStarStore]: loads this account's saved wrap ids + * into [inbox] on construction, then writes every later change back. Construct once per account. + */ +@Stable +class ConcordDirectInviteDeclineStore( + private val store: DataStore, + private val scope: CoroutineScope, + private val pubKeyHex: HexKey, + private val inbox: ConcordDirectInviteInbox, +) { + private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex") + + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + inbox.declined.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val raw = store.data.first()[key] ?: return + if (raw.isNotEmpty()) inbox.restoreDeclined(raw + inbox.declined.value) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("ConcordDirectInvites") { "Error reading declined invites: ${e.message}" } + } + } + + private suspend fun persist(ids: Set) { + try { + store.edit { prefs -> prefs[key] = ids } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("ConcordDirectInvites") { "Error writing declined invites: ${e.message}" } + } + } + + companion object { + private const val KEY_PREFIX = "concord.declinedDirectInvites." + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt new file mode 100644 index 0000000000..05296629f5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleScope +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * CORD-05 §6 send side: a Direct Invite carries exactly the Private Channel keys the recipient's + * Roles entitle them to (Armada `vendableChannels`, audience "member"), and goes to the + * recipient's 10050 → NIP-65 read → stock relays. + */ +class ConcordDirectInviteActionsTest { + private val owner = NostrSignerInternal(KeyPair()) + private val mod = NostrSignerInternal(KeyPair()) + private val member = NostrSignerInternal(KeyPair()) + + private val modsChannel = "a1".repeat(32) + private val vipChannel = "b2".repeat(32) + private val modsRoleId = ByteArray(32) { 7 } + + private fun entryOf(community: NewConcordCommunity) = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + privateChannels = + listOf( + PrivateChannelKey(modsChannel, "ca".repeat(32), 2, "mods"), + PrivateChannelKey(vipChannel, "db".repeat(32), 0, "vip"), + ), + relays = listOf("wss://relay.example"), + name = "Nostrichs", + ) + + /** A community where [mod] holds a Role scoped to [modsChannel]; nobody is scoped to [vipChannel]. */ + private suspend fun foldWithModsRole(community: NewConcordCommunity): ConcordCommunityState { + val cp = community.controlPlane + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + fun add(wrap: Event) { + editions += ConcordActions.controlEditions(listOf(wrap), cp) + } + val role = + RoleEntity( + roleId = modsRoleId.toHexKey(), + name = "Mods", + position = 5, + permissions = ConcordPermissions.of(ConcordPermissions.MENTION_EVERYONE).toWire(), + scope = RoleScope(kind = "channel", channelId = modsChannel), + ) + add(ConcordModeration.defineRole(owner, cp, community.communityId, modsRoleId, role, editions, createdAt = 2L, owner = community.ownerPubKey)) + add(ConcordModeration.grant(owner, cp, community.communityId, mod.pubKey, listOf(modsRoleId.toHexKey()), editions, createdAt = 3L, owner = community.ownerPubKey)) + return ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) + } + + @Test + fun aDirectInviteCarriesOnlyTheChannelsTheRecipientIsEntitledTo() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val state = foldWithModsRole(community) + assertTrue(modsRoleId.toHexKey() in state.authority.rolesOf(mod.pubKey)) + val entry = entryOf(community) + + // A plain member holds no channel-scoped Role: no Private Channel keys. + val toMember = ConcordActions.directInviteFor(entry, state.authority, member.pubKey, creator = owner.pubKey) + assertTrue(toMember.channels.isEmpty()) + + // The mod gets #mods (their Role's scope) and nothing else. + val toMod = ConcordActions.directInviteFor(entry, state.authority, mod.pubKey, creator = owner.pubKey, expiresAtMs = 1_900_000_000_000L) + assertEquals(listOf(modsChannel), toMod.channels.map { it.id }) + assertEquals("ca".repeat(32), toMod.channels.single().key) + assertEquals(2L, toMod.channels.single().epoch) + assertEquals(1_900_000_000_000L, toMod.expiresAt) + assertEquals(owner.pubKey, toMod.creatorNpub) + + // The owner is entitled to every channel. + val toOwner = ConcordActions.directInviteFor(entry, state.authority, owner.pubKey, creator = mod.pubKey) + assertEquals(setOf(modsChannel, vipChannel), toOwner.channels.map { it.id }.toSet()) + + // The bundle is the held base, and it validates as a fetched one would. + assertEquals(entry.root, toMember.communityRoot) + assertEquals(entry.rootEpoch, toMember.rootEpoch) + assertEquals(entry.controlPk, toMember.controlPk) + } + + @Test + fun theBuiltWrapOpensForTheRecipient() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val state = foldWithModsRole(community) + val invite = ConcordActions.directInviteFor(entryOf(community), state.authority, mod.pubKey, creator = owner.pubKey) + val wrap = ConcordActions.buildDirectInvite(owner, mod.pubKey, invite) + + // The indexed lookup a recipient runs matches the wrap's tags. + val filter = ConcordActions.directInvitesFilter(mod.pubKey, since = 5L) + assertEquals(listOf(mod.pubKey), filter.tags?.get("p")) + assertEquals(listOf("3313"), filter.tags?.get("k")) + assertEquals(5L, filter.since) + assertTrue(filter.match(wrap)) + + val opened = assertNotNull(ConcordActions.openDirectInvite(wrap, mod)) + assertEquals(owner.pubKey, opened.sender) + assertEquals(listOf(modsChannel), ConcordActions.privateChannelKeysOf(opened.invite).map { it.channelId }) + } + + @Test + fun deliveryGoesTo10050ThenNip65ReadThenStock() { + val dm = RelayUrlNormalizer.normalizeOrNull("wss://dm.example")!! + val withDm = RecipientRelayFetcher.Lists(dmInbox = listOf(dm), keyPackage = emptyList(), nip65 = null) + assertEquals(setOf(dm), ConcordActions.directInviteDeliveryRelays(withDm)) + + val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + assertEquals(stock, ConcordActions.directInviteDeliveryRelays(null)) + assertEquals(stock, ConcordActions.directInviteDeliveryRelays(RecipientRelayFetcher.Lists(emptyList(), emptyList(), null))) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt new file mode 100644 index 0000000000..d50201fabb --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * The headless Direct Invite inbox (CORD-05 §6): collects wraps, dedupes by wrap id, skips expired + * handoffs, validates, parks — and never joins. Plus the accept decision (expired → refuse; held → + * catch-up keys only on the same base, never a base move). + */ +class ConcordDirectInviteInboxTest { + private val owner = NostrSignerInternal(KeyPair()) + private val sender = NostrSignerInternal(KeyPair()) + private val me = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + + private val vip = "b2".repeat(32) + + private suspend fun community(): NewConcordCommunity = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + + private fun inviteFor( + c: NewConcordCommunity, + expiresAt: Long? = null, + channels: List = emptyList(), + root: String = c.communityRoot.toHexKey(), + ) = CommunityInvite( + communityId = c.communityIdHex, + owner = c.ownerPubKey, + ownerSalt = c.ownerSalt.toHexKey(), + communityRoot = root, + rootEpoch = c.rootEpoch, + controlPk = c.controlPkHex, + channels = channels, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + expiresAt = expiresAt, + ) + + private fun heldEntryOf(c: NewConcordCommunity) = + ConcordCommunityListEntry( + id = c.communityIdHex, + owner = c.ownerPubKey, + ownerSalt = c.ownerSalt.toHexKey(), + root = c.communityRoot.toHexKey(), + rootEpoch = c.rootEpoch, + controlPk = c.controlPkHex, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + inviteRef = "anchor", + ) + + private fun stateOf(c: NewConcordCommunity): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(c.genesisWraps, c.controlPlane), c.communityId, c.ownerPubKey) + + @Test + fun aValidWrapIsParkedWithItsVerifiedSenderAndDedupedByWrapId() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + + val first = assertNotNull(inbox.offer(wrap)) + assertEquals(sender.pubKey, first.sender) + assertEquals(c.communityIdHex, first.invite.communityId) + assertEquals(setOf(wrap.id), inbox.pending.value.keys) + + // The same wrap again (a re-delivery, or the DM pipeline seeing it too) is the same entry. + assertSame(first, inbox.offer(wrap)) + assertEquals(1, inbox.pending.value.size) + assertEquals(wrap.createdAt, inbox.newestWrapCreatedAt) + } + + @Test + fun wrapsForSomeoneElseOrForgedOrExpiredAreNotParked() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + // Addressed to someone else. + assertNull(inbox.offer(ConcordActions.buildDirectInvite(sender, stranger.pubKey, inviteFor(c)))) + // A bundle whose owner proof fails. + assertNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c).copy(owner = stranger.pubKey)))) + // A handoff whose NIP-40 expiration passed is never decrypted. + val expired = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, expiresAt = 1_000_000L)) + assertNull(inbox.offer(expired, nowSecs = 1_000L)) + assertTrue(inbox.pending.value.isEmpty()) + } + + @Test + fun theDmPipelineSealPathParksTheSameInvite() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + val seal = assertIs(wrap.unwrapOrNull(me)) + val opened = assertNotNull(inbox.offerSeal(wrap.copyNoContent(), seal)) + assertEquals(sender.pubKey, opened.sender) + assertEquals(wrap.id, opened.wrapId) + // The sweep delivering the full wrap later doesn't duplicate it. + assertSame(opened, inbox.offer(wrap)) + } + + @Test + fun declineDiscardsAndTheWrapNeverResurfaces() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + inbox.offer(wrap) + + assertTrue(inbox.decline(wrap.id)) + assertTrue(inbox.pending.value.isEmpty()) + assertEquals(setOf(wrap.id), inbox.declined.value) + assertNull(inbox.offer(wrap)) + assertFalse(inbox.decline(wrap.id)) + + // After a restart the persisted declines are restored and still win. + val fresh = ConcordDirectInviteInbox(me) + fresh.restoreDeclined(inbox.declined.value) + assertNull(fresh.offer(wrap)) + assertTrue(fresh.pending.value.isEmpty()) + } + + @Test + fun sinceRewindsTheCursorByTheBackdateWindow() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + assertNull(inbox.since()) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + inbox.offer(wrap) + assertEquals(wrap.createdAt - 2 * 24 * 60 * 60L, inbox.since()) + } + + @Test + fun visibleHidesJoinedCommunitiesButKeepsCatchUpsAndFlagsExpiry() = + runTest { + val joinedCommunity = community() + val newCommunity = community() + val inbox = ConcordDirectInviteInbox(me) + + val toNew = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(newCommunity, expiresAt = 5_000L)), nowSecs = 1L)) + val plainForJoined = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity)))) + val catchUp = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity, channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))))) + val baseMove = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity, root = "99".repeat(32), channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))))) + + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, listOf(heldEntryOf(joinedCommunity)), nowMs = 10_000L) + val byWrap = views.associateBy { it.wrapId } + assertEquals(setOf(toNew.wrapId, catchUp.wrapId), byWrap.keys) + assertFalse(plainForJoined.wrapId in byWrap) + assertFalse(baseMove.wrapId in byWrap) + assertTrue(byWrap.getValue(catchUp.wrapId).catchUp) + assertFalse(byWrap.getValue(toNew.wrapId).catchUp) + assertTrue(byWrap.getValue(toNew.wrapId).expired) + assertFalse(byWrap.getValue(catchUp.wrapId).expired) + assertEquals(listOf("vip"), byWrap.getValue(catchUp.wrapId).channelNames) + } + + @Test + fun visibleKeepsOneInvitePerCommunity() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val older = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = 1_700_000_000L))) + val newer = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = 1_700_000_100L))) + assertEquals(2, inbox.pending.value.size) + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList()) + assertEquals(listOf(newer.wrapId), views.map { it.wrapId }) + assertFalse(older.wrapId in views.map { it.wrapId }) + } + + @Test + fun acceptRefusesAnExpiredInvite() = + runTest { + val c = community() + val opened = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, expiresAt = 5_000L)), me)) + assertEquals(DirectInviteAcceptPlan.Expired, ConcordDirectInviteInbox.acceptPlan(opened, null, null, me.pubKey, nowMs = 5_001L)) + assertEquals(DirectInviteAcceptPlan.Join, ConcordDirectInviteInbox.acceptPlan(opened, null, null, me.pubKey, nowMs = 4_999L)) + } + + @Test + fun acceptOnAHeldCommunityOnlyAddsKeysAndNeverMovesTheBase() = + runTest { + val c = community() + val held = heldEntryOf(c) + val state = stateOf(c) + val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")) + + // Same base, new key: a catch-up that keeps the held base and anchor. + val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me)) + val plan = assertIs(ConcordDirectInviteInbox.acceptPlan(catchUp, held, state, me.pubKey)) + assertEquals(held.root, plan.entry.root) + assertEquals(held.rootEpoch, plan.entry.rootEpoch) + assertEquals(held.controlPk, plan.entry.controlPk) + assertEquals("anchor", plan.entry.inviteRef) + assertEquals(listOf(vip), plan.entry.privateChannels.map { it.channelId }) + + // No fold yet: the ban verdict is unknown, so it waits. + assertEquals(DirectInviteAcceptPlan.RosterNotLoaded, ConcordDirectInviteInbox.acceptPlan(catchUp, held, null, me.pubKey)) + + // Already holding that key: nothing new. + val holding = held.let { ConcordCommunityListEntry(it.id, it.owner, it.ownerSalt, it.root, it.rootEpoch, it.controlPk, privateChannels = listOf(PrivateChannelKey(vip, "db".repeat(32), 0, "vip")), relays = it.relays, name = it.name) } + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, holding, state, me.pubKey)) + + // A different base for a held community is never adopted, keys or not. + val baseMove = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, root = "99".repeat(32), channels = grant)), me)) + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(baseMove, held, state, me.pubKey)) + + // A dissolved community takes no new keys. + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, held, state.withDissolved(true), me.pubKey)) + } + + @Test + fun acceptRefusesACatchUpWhenTheHeldRosterBansUs() = + runTest { + val c = community() + val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList() + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, c.controlPlane, c.communityId, me.pubKey, editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane) + val banned = ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey) + assertTrue(banned.authority.isBanned(me.pubKey)) + + val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))), me)) + assertEquals(DirectInviteAcceptPlan.Banned, ConcordDirectInviteInbox.acceptPlan(catchUp, heldEntryOf(c), banned, me.pubKey)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index ceda69c3cd..7f4d0d6661 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -704,4 +704,27 @@ object ConcordCommunityList { excludedAtEpoch = excludedAtEpoch, residue = residue, ) + + /** + * Copy of this entry holding [privateChannels] — e.g. after a Direct Invite catch-up delivered a + * Private Channel key (CORD-05 §6). Every other field, the base included, untouched. + */ + fun ConcordCommunityListEntry.withPrivateChannels(privateChannels: List) = + ConcordCommunityListEntry( + id = id, + owner = owner, + ownerSalt = ownerSalt, + root = root, + rootEpoch = rootEpoch, + controlPk = controlPk, + controlRoot = controlRoot, + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = relays, + name = name, + addedAt = addedAt, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + residue = residue, + ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt index ac0ce7bd16..d3c74c06ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt @@ -24,18 +24,51 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag +import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils /** - * Direct invites (CORD-05): for a known npub, the invite skips the public bundle - * and is delivered as a standard NIP-59 giftwrap — a kind-3313 rumor carrying the - * [CommunityInvite], sealed (kind 13) to the recipient and wrapped (kind 1059) - * with `["p", recipient]` and a `["k", "3313"]` index tag so the recipient can - * query for pending invites without decrypting every giftwrap. + * A Direct Invite opened by its recipient (CORD-05 §6): the bundle plus the seal-verified [sender]. + * + * [invite] is already bounded and owner-proof validated ([ConcordInviteBundle.validate]); expiry is + * NOT enforced here — a parked invite still renders, only joining refuses ([isExpired]). + * [sentAt] is the rumor's `created_at` (unix seconds), the sender's word: fine for ordering, never + * for authority. + */ +class OpenedDirectInvite( + val wrapId: HexKey, + val sender: HexKey, + val invite: CommunityInvite, + val sentAt: Long, +) { + /** True when the bundle's `expires_at` (unix ms) has passed: the preview renders, joining refuses. */ + fun isExpired(nowMs: Long = TimeUtils.nowMillis()): Boolean = ConcordInviteBundle.isExpired(invite, nowMs) +} + +/** + * Direct invites (CORD-05 §6): for a known npub, the invite skips the public bundle + * and is delivered as a *standard* NIP-59 giftwrap — a kind-3313 rumor carrying the + * [CommunityInvite], sealed (kind 13, signed by the inviter's real key) to the recipient and + * wrapped (kind 1059, ephemeral single-use author) with `["p", recipient]` and a `["k", "3313"]` + * index tag so the recipient can query for pending invites without decrypting every giftwrap. + * Not the reversed stream wrap of CORD-01. + * + * Wire details pinned to Armada's `directInvite.ts`: + * - seal and wrap `created_at` are each tweaked into the past by up to [MAX_BACKDATE_SECS] + * (NIP-59), so the wrap leaks only "roughly when"; the rumor keeps the real send time; + * - when the bundle has an `expires_at` (unix ms) the wrap carries the matching NIP-40 + * `["expiration", expires_at / 1000]`, so relays can prune a handoff that can no longer be used; + * - opening requires the rumor's claimed author to equal the seal's author (NIP-59 anti-spoofing), + * and the seal's signature to verify — the seal is what proves who invited. * * It cannot be revoked — the recipient holds the keys the moment it lands. */ @@ -44,46 +77,125 @@ object ConcordDirectInvite { const val TAG_P = "p" const val TAG_K = "k" + /** NIP-59: outer (seal + wrap) timestamps are tweaked into the past by up to two days. */ + const val MAX_BACKDATE_SECS: Long = 2 * 24 * 60 * 60L + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) + /** [now] minus a uniformly random `0 until` [MAX_BACKDATE_SECS] seconds (NIP-59's timestamp tweak). */ + fun tweakedPast(now: Long = TimeUtils.now()): Long = now - RandomInstance.int(MAX_BACKDATE_SECS.toInt()) + /** * Builds a giftwrapped direct invite from [senderSigner] to [recipientPubKey]. - * Returns the kind-1059 wrap to publish to the recipient's inbox relays. + * Returns the kind-1059 wrap to publish to the recipient's inbox relays (their kind-10050 DM + * relays, else their NIP-65 read relays). [createdAt] is the rumor's real send time; the seal and + * the wrap are each backdated from it independently ([tweakedPast]). */ suspend fun build( senderSigner: NostrSigner, recipientPubKey: HexKey, invite: CommunityInvite, - createdAt: Long, + createdAt: Long = TimeUtils.now(), ): GiftWrapEvent { val rumor = RumorAssembler.assembleRumor(senderSigner.pubKey, createdAt, KIND, emptyArray(), json(invite)) - val seal = SealEvent.create(rumor, recipientPubKey, senderSigner, createdAt = createdAt) + val seal = SealEvent.create(rumor, recipientPubKey, senderSigner, createdAt = tweakedPast(createdAt)) - // Wrap with a random ephemeral key, adding the ["k","3313"] index tag. + // Wrap with a random single-use key, adding the ["k","3313"] index tag and, when the bundle + // expires, the NIP-40 expiration matching it. val wrapSigner = NostrSignerInternal(KeyPair()) val content = wrapSigner.nip44Encrypt(seal.toJson(), recipientPubKey) + val tags = + listOfNotNull( + arrayOf(TAG_P, recipientPubKey), + arrayOf(TAG_K, KIND.toString()), + invite.expiresAt?.let { arrayOf(ExpirationTag.TAG_NAME, (it / 1000).toString()) }, + ).toTypedArray() return wrapSigner.sign( - createdAt = createdAt, + createdAt = tweakedPast(createdAt), kind = GiftWrapEvent.KIND, - tags = arrayOf(arrayOf(TAG_P, recipientPubKey), arrayOf(TAG_K, KIND.toString())), + tags = tags, content = content, ) } + /** + * True when [wrap]'s NIP-40 `expiration` (unix seconds) is at or before [nowSecs]: an expired + * handoff is never decrypted or surfaced. + */ + fun isWrapExpired( + wrap: Event, + nowSecs: Long = TimeUtils.now(), + ): Boolean = wrap.tags.isExpirationBefore(nowSecs) + + /** + * The `since` to query invite wraps from, given the newest wrap `created_at` already seen: + * rewound by [MAX_BACKDATE_SECS] because wraps are backdated (a wrap published after the last + * sweep can carry an older timestamp). Null on a cold inbox — fetch everything. + */ + fun inboxSince(newestWrapCreatedAt: Long?): Long? = newestWrapCreatedAt?.takeIf { it > MAX_BACKDATE_SECS }?.let { it - MAX_BACKDATE_SECS } + + /** + * Opens a direct-invite giftwrap addressed to [recipientSigner]. Null — never a throw — unless + * every layer checks out: a kind-1059 wrap that decrypts to a kind-13 seal with a valid + * signature, whose rumor claims the seal's author (anti-spoofing), is kind 3313 (the rumor kind + * is the authority, not the outer `k` hint), and carries a [CommunityInvite] that passes the §1 + * bounds and the owner proof ([ConcordInviteBundle.validate]). + */ + suspend fun open( + wrap: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? { + if (wrap.kind != GiftWrapEvent.KIND) return null + val seal = + try { + Event.fromJson(recipientSigner.nip44Decrypt(wrap.content, wrap.pubKey)) + } catch (_: Exception) { + return null + } + return openSeal(wrap.id, seal, recipientSigner) + } + + /** + * [open] from the kind-13 [seal] down, for a pipeline that already peeled the wrap [wrapId] + * (e.g. the general NIP-17 giftwrap inbox, which honours an untagged invite all the same). + */ + suspend fun openSeal( + wrapId: HexKey, + seal: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? { + if (seal !is SealEvent) return null + return try { + if (!seal.verify()) return null + val rumor = Rumor.fromJson(recipientSigner.nip44Decrypt(seal.content, seal.pubKey)) + // NIP-59 anti-spoofing: the rumor's claimed author must be the seal's signer. The generic + // unseal path overwrites the rumor's pubkey with the seal's, which hides a mismatch; here + // a mismatch is a forgery and the whole invite is refused. + val claimed = rumor.pubKey ?: return null + if (!claimed.equals(seal.pubKey, ignoreCase = true)) return null + if (rumor.kind != KIND) return null + // Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply"), and validated + // exactly as one: the community_id must self-certify the owner. + val content = rumor.content ?: return null + val invite = + ConcordJson + .decodeOrNull(content) + ?.let { ConcordInviteBundle.bound(it) } + ?.takeIf { ConcordInviteBundle.validate(it) } + ?: return null + OpenedDirectInvite(wrapId, seal.pubKey.lowercase(), invite, rumor.createdAt ?: seal.createdAt) + } catch (_: Exception) { + null + } + } + /** * Opens a direct-invite giftwrap addressed to [recipientSigner] and returns the - * [CommunityInvite], or null if it isn't a valid direct invite for this user. - * Callers should still [ConcordInviteBundle.validate] the result. + * [CommunityInvite], or null if it isn't a valid direct invite for this user. See [open], which + * also returns the verified sender. */ suspend fun parse( wrap: GiftWrapEvent, recipientSigner: NostrSigner, - ): CommunityInvite? { - val seal = wrap.unwrapOrNull(recipientSigner) ?: return null - if (seal !is SealEvent) return null - val rumor = seal.unsealOrNull(recipientSigner) ?: return null - if (rumor.kind != KIND) return null - // Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply"). - return ConcordJson.decodeOrNull(rumor.content)?.let { ConcordInviteBundle.bound(it) } - } + ): CommunityInvite? = open(wrap, recipientSigner)?.invite } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt new file mode 100644 index 0000000000..c0cb4df4d9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withPrivateChannels +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Which Private Channel keys an invite bundle may carry (CORD-05 §1, CORD-03 §1, CORD-04 §2), and + * what a bundle for an already-joined community may contribute. Pinned to Armada's + * `channelAccess.ts` (`isEntitled`, `vendableChannels`) and `directInvite.ts` (`catchUpChannelIds`). + * + * The Roles scoped to a channel (`scope: {kind:"channel", channel_id}`) ARE its access list. Read + * access is enforced by key possession alone; this decides who a key is delivered TO. + */ +object ConcordInviteVend { + private const val SCOPE_CHANNEL = "channel" + + /** The live Role ids conferring read access to [channelIdHex] (Roles scoped to that channel). */ + fun channelRoleIds( + authority: AuthorityResolver, + channelIdHex: HexKey, + ): Set = + authority + .roles() + .filter { (_, role) -> !role.deleted && role.scope?.kind == SCOPE_CHANNEL && role.scope.channelId.equals(channelIdHex, ignoreCase = true) } + .keys + + /** + * Is [memberHex] entitled to Private Channel [channelIdHex]'s key? The owner always is + * (CORD-04 §2); anyone else must hold a Role scoped to that channel. + */ + fun isEntitled( + authority: AuthorityResolver, + memberHex: HexKey, + channelIdHex: HexKey, + ): Boolean { + if (authority.isOwner(memberHex)) return true + val held = authority.rolesOf(memberHex) + if (held.isEmpty()) return false + return channelRoleIds(authority, channelIdHex).any { it in held } + } + + /** + * The held Private Channel keys a bundle may carry for its audience (CORD-05 §1): + * - a **link** ([memberHex] null) has no recipient and holds no Role, so it gets none; + * - a **member** (a Direct Invite's recipient) gets exactly the channels their Roles entitle + * them to ([isEntitled]) — that CORD-05 §6 can't *prevent* an unentitled whisper doesn't make + * one right. + * + * Keyless listings are never vended. + */ + fun vendableChannels( + held: List, + authority: AuthorityResolver, + memberHex: HexKey?, + ): List { + if (memberHex == null) return emptyList() + return held.filter { it.key.isNotBlank() && isEntitled(authority, memberHex, it.channelId) } + } + + /** The [held] keys as bundle channel grants (lowercase hex, as Armada writes them). */ + fun toInviteChannels(held: List): List = held.map { InviteChannel(it.channelId.lowercase(), it.key.lowercase(), it.epoch, it.name) } + + /** + * The Private Channel ids (lowercase hex) a [bundle] for an already-joined community would NEWLY + * contribute to [held] — empty when it is not a catch-up. Armada `catchUpChannelIds`. + * + * A catch-up may never move the base: nothing binds `community_root` to `community_id` + * (CORD-02 §1/§2), so a hostile bundle carrying a real id/owner/salt could otherwise relocate + * the member onto attacker-read streams. So it counts only on the SAME `community_root`, + * `root_epoch` and `control_pk` (swapping `control_pk` alone would eclipse the member onto an + * attacker's Control Plane); the base advances only by a CORD-06 rekey. + */ + fun catchUpChannelIds( + held: ConcordCommunityListEntry?, + bundle: CommunityInvite, + ): List { + if (held == null) return emptyList() + if (!bundle.communityId.equals(held.id, ignoreCase = true)) return emptyList() + if (!bundle.communityRoot.equals(held.root, ignoreCase = true)) return emptyList() + if (bundle.rootEpoch != held.rootEpoch) return emptyList() + if (!sameOptionalHex(bundle.controlPk, held.controlPk)) return emptyList() + val heldEpochs = held.privateChannels.filter { it.key.isNotBlank() }.associate { it.channelId.lowercase() to it.epoch } + return bundle.channels + .filter { HEX64.matches(it.id) && HEX64.matches(it.key) } + .filter { c -> + val heldEpoch = heldEpochs[c.id.lowercase()] + heldEpoch == null || c.epoch > heldEpoch + }.map { it.id.lowercase() } + .distinct() + } + + /** + * [held] with the Private Channel keys [bundle] newly contributes ([catchUpChannelIds]) merged + * in — a newer epoch replaces the held one — or null when the bundle contributes nothing. The + * base, epoch, control keys and every other field stay exactly as held. + */ + fun adoptCatchUp( + held: ConcordCommunityListEntry, + bundle: CommunityInvite, + ): ConcordCommunityListEntry? { + val newIds = catchUpChannelIds(held, bundle).toSet() + if (newIds.isEmpty()) return null + val delivered = + bundle.channels + .filter { it.id.lowercase() in newIds && HEX64.matches(it.key) } + .groupBy { it.id.lowercase() } + .map { (id, grants) -> grants.maxBy { it.epoch }.let { PrivateChannelKey(id, it.key.lowercase(), it.epoch, it.name) } } + val kept = held.privateChannels.filterNot { it.channelId.lowercase() in newIds } + return held.withPrivateChannels(kept + delivered) + } + + private val HEX64 = Regex("^[0-9a-fA-F]{64}$") + + private fun sameOptionalHex( + a: String?, + b: String?, + ): Boolean = a?.lowercase() == b?.lowercase() +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt index 396ffbad1b..985da96538 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt @@ -20,47 +20,203 @@ */ package com.vitorpamplona.quartz.concord.cord05Invites +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue class ConcordDirectInviteTest { + private val owner = NostrSignerInternal(KeyPair()) private val sender = NostrSignerInternal(KeyPair()) private val recipient = NostrSignerInternal(KeyPair()) private val stranger = NostrSignerInternal(KeyPair()) - private val invite = - CommunityInvite( - communityId = "11".repeat(32), - owner = "0f".repeat(32), - ownerSalt = "aa".repeat(32), - communityRoot = "bb".repeat(32), - name = "Nostrichs", + private suspend fun community(): NewConcordCommunity = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + + private fun inviteFor( + community: NewConcordCommunity, + expiresAt: Long? = null, + relays: List = listOf("wss://relay.example"), + channels: List = emptyList(), + ) = CommunityInvite( + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + communityRoot = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + channels = channels, + relays = relays, + name = "Nostrichs", + expiresAt = expiresAt, + ) + + /** Wraps an arbitrary [seal] to [to] exactly like [ConcordDirectInvite.build] does (ephemeral author, p + k tags). */ + private suspend fun wrapSeal( + seal: Event, + to: String, + ): GiftWrapEvent { + val eph = NostrSignerInternal(KeyPair()) + return eph.sign( + createdAt = seal.createdAt, + kind = GiftWrapEvent.KIND, + tags = arrayOf(arrayOf("p", to), arrayOf("k", "3313")), + content = eph.nip44Encrypt(seal.toJson(), to), ) + } + + /** A seal from [sealer] carrying a kind-[kind] rumor that CLAIMS [claimedAuthor]. */ + private suspend fun forgedSeal( + sealer: NostrSigner, + claimedAuthor: String, + content: String, + kind: Int = ConcordDirectInvite.KIND, + ): SealEvent { + val rumor = RumorAssembler.assembleRumor(claimedAuthor, 1_700_000_000L, kind, emptyArray(), content) + return SealEvent.create(rumor, recipient.pubKey, sealer, createdAt = 1_700_000_000L) + } + + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) @Test - fun directInviteRoundTripsToTheRecipient() = + fun directInviteRoundTripsWithTheVerifiedSender() = runTest { - val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1_700_000_000L) + val c = community() + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = 1_700_000_000L) - // Wrap is a giftwrap tagged for the recipient and indexable by k=3313. + // Wrap is a giftwrap tagged for the recipient and indexable by k=3313, from an ephemeral author. + assertEquals(GiftWrapEvent.KIND, wrap.kind) assertEquals(recipient.pubKey, wrap.tags.first { it[0] == "p" }[1]) assertEquals("3313", wrap.tags.first { it[0] == "k" }[1]) + assertFalse(wrap.pubKey == sender.pubKey) - val parsed = ConcordDirectInvite.parse(wrap, recipient) - assertNotNull(parsed) - assertEquals("Nostrichs", parsed.name) - assertEquals("11".repeat(32), parsed.communityId) + val opened = ConcordDirectInvite.open(wrap, recipient) + assertNotNull(opened) + assertEquals(sender.pubKey, opened.sender) + assertEquals(wrap.id, opened.wrapId) + assertEquals(1_700_000_000L, opened.sentAt) + assertEquals("Nostrichs", opened.invite.name) + assertEquals(c.communityIdHex, opened.invite.communityId) + assertEquals(c.controlPkHex, opened.invite.controlPk) + + // The legacy parse keeps working. + assertEquals(c.communityIdHex, ConcordDirectInvite.parse(wrap, recipient)?.communityId) } @Test fun strangersCannotOpenIt() = runTest { - val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1L) - assertNull(ConcordDirectInvite.parse(wrap, stranger)) + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(community()), createdAt = 1L) + assertNull(ConcordDirectInvite.open(wrap, stranger)) } + + @Test + fun aRumorClaimingSomeoneElseIsRefused() = + runTest { + // The attacker seals (and so is the verified sender) a rumor claiming the owner wrote it. + val c = community() + val spoofed = wrapSeal(forgedSeal(stranger, claimedAuthor = owner.pubKey, content = json(inviteFor(c))), recipient.pubKey) + assertNull(ConcordDirectInvite.open(spoofed, recipient)) + + // The very same rumor claiming its real sealer opens. + val honest = wrapSeal(forgedSeal(stranger, claimedAuthor = stranger.pubKey, content = json(inviteFor(c))), recipient.pubKey) + assertEquals(stranger.pubKey, ConcordDirectInvite.open(honest, recipient)?.sender) + } + + @Test + fun theRumorKindIsTheAuthorityNotTheKTag() = + runTest { + // A k=3313-tagged wrap whose rumor is a kind-14 DM is not an invite. + val c = community() + val dm = wrapSeal(forgedSeal(sender, claimedAuthor = sender.pubKey, content = json(inviteFor(c)), kind = 14), recipient.pubKey) + assertNull(ConcordDirectInvite.open(dm, recipient)) + } + + @Test + fun wrapCarriesNip40ExpirationMatchingExpiresAt() = + runTest { + val c = community() + val expiresAtMs = 1_800_000_123_456L + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, expiresAt = expiresAtMs), createdAt = 1_700_000_000L) + assertEquals(1_800_000_123L, wrap.tags.expiration()) + + assertFalse(ConcordDirectInvite.isWrapExpired(wrap, nowSecs = 1_800_000_122L)) + assertTrue(ConcordDirectInvite.isWrapExpired(wrap, nowSecs = 1_800_000_123L)) + + // No expires_at, no expiration tag. + val open = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = 1_700_000_000L) + assertNull(open.tags.expiration()) + assertFalse(ConcordDirectInvite.isWrapExpired(open, nowSecs = Long.MAX_VALUE)) + + // An expired bundle still opens (a parked invite renders), but reports itself expired. + val opened = ConcordDirectInvite.open(wrap, recipient) + assertNotNull(opened) + assertTrue(opened.isExpired(nowMs = expiresAtMs + 1)) + assertFalse(opened.isExpired(nowMs = expiresAtMs - 1)) + } + + @Test + fun sealAndWrapAreBackdatedWithinTwoDaysButTheRumorKeepsTheRealTime() = + runTest { + val c = community() + val now = 1_700_000_000L + val outer = mutableListOf() + repeat(6) { + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = now) + val seal = wrap.unwrapOrNull(recipient) + assertIs(seal) + for (t in listOf(wrap.createdAt, seal.createdAt)) { + assertTrue(t <= now, "outer timestamp $t is in the future") + assertTrue(t > now - ConcordDirectInvite.MAX_BACKDATE_SECS, "outer timestamp $t is backdated past two days") + outer += t + } + assertEquals(now, ConcordDirectInvite.open(wrap, recipient)?.sentAt) + } + // Twelve independent draws over a two-day range are not all "now". + assertTrue(outer.any { it < now }) + } + + @Test + fun theSection1BoundsApply() = + runTest { + val c = community() + val sixRelays = (1..6).map { "wss://r$it.example" } + val bounded = ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, relays = sixRelays), createdAt = 1L), recipient) + assertEquals(sixRelays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), bounded?.invite?.relays) + + val tooMany = (0..ConcordInviteBundle.MAX_BUNDLE_CHANNELS).map { InviteChannel(id = it.toString(16).padStart(64, '0'), key = "cd".repeat(32), epoch = 0) } + assertNull(ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, channels = tooMany), createdAt = 1L), recipient)) + } + + @Test + fun aBundleWhoseOwnerProofFailsIsRefused() = + runTest { + // A real community's id with someone else's owner: the id does not self-certify it. + val c = community() + val forged = inviteFor(c).copy(owner = stranger.pubKey) + assertNull(ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, forged, createdAt = 1L), recipient)) + } + + @Test + fun inboxSinceRewindsByTheBackdateWindow() { + assertNull(ConcordDirectInvite.inboxSince(null)) + assertNull(ConcordDirectInvite.inboxSince(100L)) + assertEquals(1_700_000_000L - ConcordDirectInvite.MAX_BACKDATE_SECS, ConcordDirectInvite.inboxSince(1_700_000_000L)) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt new file mode 100644 index 0000000000..fc7577a4c6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A Direct Invite for an already-joined community is a catch-up: it may only add Private Channel + * keys on the SAME base (root, epoch, control_pk) — never move the base (Armada `catchUpChannelIds`). + */ +class ConcordInviteVendTest { + private val communityId = "11".repeat(32) + private val root = "22".repeat(32) + private val controlPk = "33".repeat(32) + private val chanA = "a1".repeat(32) + private val chanB = "b2".repeat(32) + private val keyA = "ca".repeat(32) + private val keyB = "db".repeat(32) + + private val held = + ConcordCommunityListEntry( + id = communityId, + owner = "44".repeat(32), + ownerSalt = "55".repeat(32), + root = root, + rootEpoch = 3, + controlPk = controlPk, + privateChannels = listOf(PrivateChannelKey(chanA, keyA, 1, "mods")), + relays = listOf("wss://relay.example"), + name = "Nostrichs", + inviteRef = "naddr1ref", + ) + + private fun bundle( + root: String = this.root, + epoch: Long = 3, + controlPk: String? = this.controlPk, + channels: List, + ) = CommunityInvite( + communityId = communityId, + owner = held.owner, + ownerSalt = held.ownerSalt, + communityRoot = root, + rootEpoch = epoch, + controlPk = controlPk, + channels = channels, + name = "Nostrichs", + ) + + @Test + fun aNewPrivateChannelKeyOnTheSameBaseIsACatchUp() { + val b = bundle(channels = listOf(InviteChannel(chanA, keyA, 1, "mods"), InviteChannel(chanB.uppercase(), keyB, 0, "vip"))) + assertEquals(listOf(chanB), ConcordInviteVend.catchUpChannelIds(held, b)) + + val adopted = ConcordInviteVend.adoptCatchUp(held, b) + assertNotNull(adopted) + // The base never moves. + assertEquals(root, adopted.root) + assertEquals(3, adopted.rootEpoch) + assertEquals(controlPk, adopted.controlPk) + assertEquals(held.inviteRef, adopted.inviteRef) + assertEquals(setOf(chanA to keyA, chanB to keyB), adopted.privateChannels.map { it.channelId to it.key }.toSet()) + } + + @Test + fun aNewerEpochOfAHeldChannelReplacesIt() { + val newer = "ee".repeat(32) + val b = bundle(channels = listOf(InviteChannel(chanA, newer, 2, "mods"))) + assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(held, b)) + val adopted = assertNotNull(ConcordInviteVend.adoptCatchUp(held, b)) + assertEquals(listOf(Triple(chanA, newer, 2L)), adopted.privateChannels.map { Triple(it.channelId, it.key, it.epoch) }) + + // Same or older epoch contributes nothing. + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanA, newer, 1)))).isEmpty()) + } + + @Test + fun aBundleOnAnotherBaseIsNeverACatchUp() { + val grant = listOf(InviteChannel(chanB, keyB, 0, "vip")) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(root = "99".repeat(32), channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(epoch = 4, channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(controlPk = "98".repeat(32), channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(controlPk = null, channels = grant)).isEmpty()) + assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(root = "99".repeat(32), channels = grant))) + } + + @Test + fun nothingHeldMeansNoCatchUpAndKeylessGrantsDeliverNothing() { + assertTrue(ConcordInviteVend.catchUpChannelIds(null, bundle(channels = listOf(InviteChannel(chanB, keyB, 0)))).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanB, "", 0)))).isEmpty()) + assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(channels = emptyList()))) + } +}