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