diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt new file mode 100644 index 0000000000..95d94ffc61 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -0,0 +1,69 @@ +/* + * 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 kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** An encrypted-media image reference (CORD-02): where the bytes are and how to decrypt them. */ +@Serializable +class ImagePointer( + val url: String = "", + val key: String = "", + val nonce: String = "", + val hash: String = "", +) + +/** A channel grant carried in an invite: its id, delivered [key], [epoch], and [name]. */ +@Serializable +class InviteChannel( + val id: String, + val key: String, + val epoch: Long, + val name: String = "", +) + +/** + * The contents of a Concord invite (CORD-05) — everything a joiner needs to + * become a member: the self-certifying [communityId] with its [owner]/[ownerSalt] + * proof, the access [communityRoot] at [rootEpoch], per-[channels] grants, + * bootstrap [relays], display [name]/[icon], optional [expiresAt] and creator + * attribution. + * + * Field names are pinned to the Concord v2 reference client (snake_case on the + * wire) so bundles interoperate. This object is JSON-serialized and encrypted — + * into a kind-33301 bundle (link invites) or a NIP-59 giftwrap (direct invites). + */ +@Serializable +class CommunityInvite( + @SerialName("community_id") val communityId: String, + val owner: String, + @SerialName("owner_salt") val ownerSalt: String, + @SerialName("community_root") val communityRoot: String, + @SerialName("root_epoch") val rootEpoch: Long = 0, + val channels: List = emptyList(), + val relays: List = emptyList(), + val name: String = "", + val icon: ImagePointer? = null, + @SerialName("expires_at") val expiresAt: Long? = null, + @SerialName("creator_npub") val creatorNpub: String? = null, + val label: String? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt new file mode 100644 index 0000000000..0d301703a4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -0,0 +1,122 @@ +/* + * 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.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.utils.RandomInstance + +/** A freshly minted public invite link: the shareable URL, the link keys, and the bundle to publish. */ +class MintedInviteLink( + val url: String, + val linkSignerPubKey: String, + val linkSignerPrivKey: ByteArray, + val token: ByteArray, + val bundleEvent: Event, +) + +/** + * The public invite bundle (CORD-05): a kind-33301 addressable event whose + * content is the [CommunityInvite] NIP-44-encrypted under the bundle key derived + * from the link's 16-byte unlock token. The event is signed by a per-link + * `link_signer` keypair (so re-posting refreshes keys) and tagged + * `["d",""],["vsk","6"]`. + * + * A server that indexes the naddr never holds the token, so it can never open the + * bundle. Pinned to the Concord v2 reference client. + */ +object ConcordInviteBundle { + const val KIND = ConcordKinds.INVITE_BUNDLE + const val TAG_D = "d" + const val TAG_VSK = "vsk" + const val VSK_LIVE = "6" + + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) + + /** Builds a kind-33301 bundle event carrying [invite], encrypted under [token] and signed by [linkSignerPrivKey]. */ + fun build( + linkSignerPrivKey: ByteArray, + token: ByteArray, + invite: CommunityInvite, + createdAt: Long, + ): Event { + val bundleKey = ConcordKeyDerivation.inviteBundleKey(token) + val content = Nip44.v2.encrypt(json(invite), bundleKey).encodePayload() + val signer = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)) + return signer.signNormal(createdAt, KIND, arrayOf(arrayOf(TAG_D, ""), arrayOf(TAG_VSK, VSK_LIVE)), content) + } + + /** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */ + fun parse( + event: Event, + token: ByteArray, + ): CommunityInvite? { + if (event.kind != KIND) return null + return try { + val bundleKey = ConcordKeyDerivation.inviteBundleKey(token) + ConcordJson.decodeOrNull(Nip44.v2.decrypt(event.content, bundleKey)) + } catch (_: Exception) { + null + } + } + + /** + * Validates that an [invite]'s owner + salt actually reproduce its + * community_id (CORD-02 self-certification), so a bundle can't smuggle a false + * owner or a fake key for a real community. + */ + fun validate(invite: CommunityInvite): Boolean { + val owner = invite.owner.hexToByteArrayOrNull() ?: return false + val salt = invite.ownerSalt.hexToByteArrayOrNull() ?: return false + return ConcordKeyDerivation.communityId(owner, salt).toHexKey() == invite.communityId + } + + /** True if the invite has an expiry in the past (blocks joining; preview still renders). Time in unix ms. */ + fun isExpired( + invite: CommunityInvite, + nowMs: Long, + ): Boolean = invite.expiresAt?.let { it < nowMs } ?: false + + /** + * Mints a complete public invite link for [invite]: generates a fresh 16-byte + * token and a per-link signer, builds the bundle event and the shareable + * `{base}/invite/{naddr}#{fragment}` URL (with optional bootstrap [relays]). + */ + fun mintLink( + base: String, + invite: CommunityInvite, + createdAt: Long, + relays: List? = null, + ): MintedInviteLink { + val token = RandomInstance.bytes(16) + val linkSigner = KeyPair() + val bundleEvent = build(linkSigner.privKey!!, token, invite, createdAt) + val url = ConcordInviteLink.buildUrl(base, linkSigner.pubKey.toHexKey(), token, relays) + return MintedInviteLink(url, linkSigner.pubKey.toHexKey(), linkSigner.privKey, token, bundleEvent) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt new file mode 100644 index 0000000000..3d365679c8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt @@ -0,0 +1,116 @@ +/* + * 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.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The full public-invite path: create a community → mint an invite link → a + * stranger redeems the link, reconstructs the root, and reads the community's + * genesis Control Plane. This is the create-and-invite flow the app drives. + */ +class ConcordInviteJoinFlowTest { + private val owner = NostrSignerInternal(KeyPair()) + + private suspend fun inviteFor(community: com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity) = + CommunityInvite( + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + communityRoot = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + ) + + @Test + fun createMintRedeemAndRead() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + // The joiner only has the URL. Extract the token from the private fragment. + val parsedUrl = ConcordInviteLink.parseUrl(minted.url) + assertNotNull(parsedUrl) + assertEquals(minted.linkSignerPubKey, parsedUrl.linkSignerPubKey) + + // Decrypt the fetched bundle with that token and verify self-certification. + val invite = ConcordInviteBundle.parse(minted.bundleEvent, parsedUrl.fragment.token) + assertNotNull(invite) + assertTrue(ConcordInviteBundle.validate(invite)) + assertEquals(community.communityIdHex, invite.communityId) + + // Reconstruct the root, derive the Control Plane, and read the genesis. + val controlPlane = + ConcordKeyDerivation.controlPlaneKey( + invite.communityRoot.hexToByteArray(), + invite.communityId.hexToByteArray(), + invite.rootEpoch, + ) + val editions = community.genesisWraps.mapNotNull { ControlEdition.fromRumor(ConcordStreamEnvelope.open(it, controlPlane).rumor) } + val state = ConcordCommunityState.fold(editions, invite.owner) + assertEquals("Nostrichs", state.metadata?.name) + assertTrue(state.channels.isNotEmpty()) // #general is visible to the new member + } + + @Test + fun wrongTokenCannotOpenTheBundle() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Secret", createdAt = 1L) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L) + assertNull(ConcordInviteBundle.parse(minted.bundleEvent, ByteArray(16) { 0x01 })) // random token fails + } + + @Test + fun validateRejectsForgedOwner() { + // owner + salt that do not reproduce the claimed community_id + val forged = + CommunityInvite( + communityId = "00".repeat(32), + owner = KeyPair().pubKey.toHexKey(), + ownerSalt = "aa".repeat(32), + communityRoot = "bb".repeat(32), + ) + assertFalse(ConcordInviteBundle.validate(forged)) + } + + @Test + fun expiryBlocksJoiningButNotPreview() { + val invite = CommunityInvite("id", "o", "s", "r", expiresAt = 1_000L) + assertTrue(ConcordInviteBundle.isExpired(invite, nowMs = 2_000L)) + assertFalse(ConcordInviteBundle.isExpired(invite, nowMs = 500L)) + } +}