feat(concord): add invite bundle (33301) and full join flow

Completes the public invite path (CORD-05), pinned to Concord v2 (Armada
invite.ts):

- CommunityInvite: the bundle contents with exact snake_case field names
  (community_id, owner, owner_salt, community_root, root_epoch, channels[],
  relays, name, icon, expires_at, creator_npub, label) + ImagePointer/InviteChannel
- ConcordInviteBundle: build/parse the kind-33301 event (content =
  nip44(CommunityInvite, inviteBundleKey(token)); tags d="",vsk="6"; signed by a
  per-link signer), self-certification validate (owner+salt reproduce
  community_id), expiry check, and mintLink (fresh token + link signer -> bundle
  event + shareable URL)

End-to-end test: create a community, mint an invite link, a stranger parses the
URL, decrypts the bundle with the fragment token, validates the owner
commitment, reconstructs the root, and reads the genesis #general channel.
Wrong-token and forged-owner rejections covered. Green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-09 23:04:46 +00:00
parent f42f587ade
commit 5669414de7
3 changed files with 307 additions and 0 deletions
@@ -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<InviteChannel> = emptyList(),
val relays: List<String> = 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,
)
@@ -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<CommunityInvite>(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<String>? = 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)
}
}
@@ -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))
}
}