mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
feat(concord): add community creation factory and entity coordinates
Completes the "create a community" path (CORD-02 Genesis), pinned to Armada (concord-v2 control.ts/community.ts): - ConcordKeyDerivation: control/guestbook plane keys and the keyless entity coordinates — grantCoordinate = hkdf32(communityId, "concord/grant"||member), banlistCoordinate (ZERO32 id), inviteLinksCoordinate (creator) - ControlEditionBuilder: assembles kind-3308 edition rumors (vsk/eid/ev/ep/vac), the inverse of ControlEdition.fromRumor - MetadataEntity gains relays - ConcordCommunityFactory.create: mints owner_salt + self-certifying community_id, an independent community_root, and two owner-signed genesis editions (metadata with eid=communityId, and a public #general channel) as plaintext-seal wraps on the Control Plane at epoch 0 Test creates a community, verifies the id commitment, opens the genesis wraps (20014 seals, owner-authored), folds them into live ConcordCommunityState with a #general channel and owner authority, and confirms one owner yields distinct communities. 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:
+141
@@ -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.cord02Community
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
/**
|
||||
* A freshly created Concord community: its self-certifying identity and access
|
||||
* secrets, plus the genesis Control Plane wraps to publish and the equivalent
|
||||
* editions to fold locally.
|
||||
*/
|
||||
class NewConcordCommunity(
|
||||
val communityId: ByteArray,
|
||||
val ownerPubKey: String,
|
||||
val ownerSalt: ByteArray,
|
||||
val communityRoot: ByteArray,
|
||||
val rootEpoch: Long,
|
||||
val generalChannelId: ByteArray,
|
||||
val controlPlane: GroupKey,
|
||||
/** The kind-1059 control-plane wraps to publish (metadata + #general). */
|
||||
val genesisWraps: List<Event>,
|
||||
/** The same editions as parsed [ControlEdition]s, for immediate local folding. */
|
||||
val genesisEditions: List<ControlEdition>,
|
||||
) {
|
||||
val communityIdHex: String get() = communityId.toHexKey()
|
||||
val generalChannelIdHex: String get() = generalChannelId.toHexKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new Concord communities (CORD-02 Genesis).
|
||||
*
|
||||
* `create` mints a random `owner_salt`, derives the self-certifying
|
||||
* `community_id = sha256("concord/community" ‖ owner ‖ salt)`, generates an
|
||||
* independent random `community_root` (so access can rotate while identity stays
|
||||
* fixed), and emits exactly two owner-signed genesis editions — the community
|
||||
* metadata and a public `#general` channel — as plaintext-seal wraps on the
|
||||
* Control Plane at epoch 0.
|
||||
*/
|
||||
object ConcordCommunityFactory {
|
||||
const val GENERAL_CHANNEL_NAME = "general"
|
||||
|
||||
suspend fun create(
|
||||
ownerSigner: NostrSigner,
|
||||
name: String,
|
||||
createdAt: Long,
|
||||
description: String? = null,
|
||||
relays: List<String> = emptyList(),
|
||||
icon: String? = null,
|
||||
): NewConcordCommunity {
|
||||
val ownerXOnly = ownerSigner.pubKey.hexToByteArray()
|
||||
val ownerSalt = ConcordKeyDerivation.newOwnerSalt()
|
||||
val communityId = ConcordKeyDerivation.communityId(ownerXOnly, ownerSalt)
|
||||
val communityRoot = RandomInstance.bytes(32)
|
||||
val generalChannelId = RandomInstance.bytes(32)
|
||||
val rootEpoch = 0L
|
||||
val controlPlane = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch)
|
||||
|
||||
val metadataJson =
|
||||
ConcordJson.instance.encodeToString(
|
||||
MetadataEntity.serializer(),
|
||||
MetadataEntity(name = name, icon = icon, description = description, relays = relays),
|
||||
)
|
||||
val channelJson =
|
||||
ConcordJson.instance.encodeToString(
|
||||
ChannelEntity.serializer(),
|
||||
ChannelEntity(name = GENERAL_CHANNEL_NAME, private = false),
|
||||
)
|
||||
|
||||
val metadataRumor =
|
||||
ControlEditionBuilder.rumor(
|
||||
authorPubKey = ownerSigner.pubKey,
|
||||
entityKind = ControlEntityKind.METADATA,
|
||||
entityId = communityId, // metadata eid == community id
|
||||
version = 0,
|
||||
prevHash = null,
|
||||
content = metadataJson,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
val channelRumor =
|
||||
ControlEditionBuilder.rumor(
|
||||
authorPubKey = ownerSigner.pubKey,
|
||||
entityKind = ControlEntityKind.CHANNEL,
|
||||
entityId = generalChannelId, // channel eid == channel id
|
||||
version = 0,
|
||||
prevHash = null,
|
||||
content = channelJson,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
// Control Plane uses plaintext (20014) seals so signatures survive re-encryption across epochs.
|
||||
val metadataWrap = ConcordStreamEnvelope.wrap(metadataRumor, controlPlane, ownerSigner, encrypted = false, createdAt = createdAt)
|
||||
val channelWrap = ConcordStreamEnvelope.wrap(channelRumor, controlPlane, ownerSigner, encrypted = false, createdAt = createdAt)
|
||||
|
||||
return NewConcordCommunity(
|
||||
communityId = communityId,
|
||||
ownerPubKey = ownerSigner.pubKey,
|
||||
ownerSalt = ownerSalt,
|
||||
communityRoot = communityRoot,
|
||||
rootEpoch = rootEpoch,
|
||||
generalChannelId = generalChannelId,
|
||||
controlPlane = controlPlane,
|
||||
genesisWraps = listOf(metadataWrap, channelWrap),
|
||||
genesisEditions =
|
||||
listOfNotNull(
|
||||
ControlEdition.fromRumor(metadataRumor),
|
||||
ControlEdition.fromRumor(channelRumor),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.concord.events.ConcordKinds
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
|
||||
/**
|
||||
* Builds unsigned kind-3308 Control Plane edition rumors (the inverse of
|
||||
* [ControlEdition.fromRumor]). Seal these with a plaintext (20014) seal and wrap
|
||||
* them on the community's Control Plane so the author's signature survives
|
||||
* re-encryption across epochs.
|
||||
*/
|
||||
object ControlEditionBuilder {
|
||||
/**
|
||||
* Assembles a control edition rumor for [entityKind]/[entityId] at [version].
|
||||
* Pass [prevHash] to chain onto the previous edition (null for genesis) and
|
||||
* [authorityCitation] to pin the Grant the [authorPubKey] acts under.
|
||||
*/
|
||||
fun rumor(
|
||||
authorPubKey: HexKey,
|
||||
entityKind: ControlEntityKind,
|
||||
entityId: ByteArray,
|
||||
version: Long,
|
||||
prevHash: ByteArray?,
|
||||
content: String,
|
||||
createdAt: Long,
|
||||
authorityCitation: AuthorityCitation? = null,
|
||||
): Event {
|
||||
val tags = ArrayList<Array<String>>(5)
|
||||
tags.add(arrayOf(ControlEdition.TAG_VSK, entityKind.wire))
|
||||
tags.add(arrayOf(ControlEdition.TAG_EID, entityId.toHexKey()))
|
||||
tags.add(arrayOf(ControlEdition.TAG_EV, version.toString()))
|
||||
if (prevHash != null) tags.add(arrayOf(ControlEdition.TAG_EP, prevHash.toHexKey()))
|
||||
if (authorityCitation != null) {
|
||||
tags.add(
|
||||
arrayOf(
|
||||
ControlEdition.TAG_VAC,
|
||||
authorityCitation.grantId.toHexKey(),
|
||||
authorityCitation.grantVersion.toString(),
|
||||
authorityCitation.grantHash.toHexKey(),
|
||||
),
|
||||
)
|
||||
}
|
||||
return RumorAssembler.assembleRumor(authorPubKey, createdAt, ConcordKinds.CONTROL, tags.toTypedArray(), content)
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -98,10 +98,14 @@ class ChannelEntity(
|
||||
val deleted: Boolean = false,
|
||||
)
|
||||
|
||||
/** A community's Metadata content (CORD-02): display name, icon, and description. */
|
||||
/**
|
||||
* A community's Metadata content (CORD-02): display [name], optional [icon] and
|
||||
* [description], and the community's bootstrap [relays]. Client-extensible.
|
||||
*/
|
||||
@Serializable
|
||||
class MetadataEntity(
|
||||
val name: String = "",
|
||||
val icon: String? = null,
|
||||
val description: String? = null,
|
||||
val relays: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
+35
@@ -180,6 +180,41 @@ object ConcordKeyDerivation {
|
||||
identity: String,
|
||||
): ByteArray = hkdf32(voiceMediaKey, buildInfo(ConcordLabels.VOICE_SENDER, sha256(identity.encodeToByteArray())))
|
||||
|
||||
// ---- Plane keys (CORD-02) -------------------------------------------------
|
||||
|
||||
/** The Control Plane address for a community at [epoch] (holders of the root only). */
|
||||
fun controlPlaneKey(
|
||||
communityRoot: ByteArray,
|
||||
communityId: ByteArray,
|
||||
epoch: Long,
|
||||
): GroupKey = groupKey(ConcordLabels.CONTROL, communityRoot, communityId, epoch)
|
||||
|
||||
/** The Guestbook Plane address for a community at [epoch]. */
|
||||
fun guestbookPlaneKey(
|
||||
communityRoot: ByteArray,
|
||||
communityId: ByteArray,
|
||||
epoch: Long,
|
||||
): GroupKey = groupKey(ConcordLabels.GUESTBOOK, communityRoot, communityId, epoch)
|
||||
|
||||
// ---- Control entity coordinates (CORD-04) ---------------------------------
|
||||
// Keyless coordinates: the community id is the HKDF ikm; distinct labels and
|
||||
// id bytes give each entity kind its own address. All raw hkdf32 (32 bytes).
|
||||
|
||||
/** The Grant entity id for a member: `hkdf32(communityId, "concord/grant" ‖ 0x00 ‖ member)`. */
|
||||
fun grantCoordinate(
|
||||
communityId: ByteArray,
|
||||
memberXOnly: ByteArray,
|
||||
): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.GRANT, memberXOnly))
|
||||
|
||||
/** The community-wide Banlist entity id: `hkdf32(communityId, "concord/banlist" ‖ 0x00 ‖ ZERO32)`. */
|
||||
fun banlistCoordinate(communityId: ByteArray): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.BANLIST, ByteArray(32)))
|
||||
|
||||
/** The invite-registry entity id for a creator: `hkdf32(communityId, "concord/invite-links" ‖ 0x00 ‖ creator)`. */
|
||||
fun inviteLinksCoordinate(
|
||||
communityId: ByteArray,
|
||||
creatorXOnly: ByteArray,
|
||||
): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.INVITE_LINKS, creatorXOnly))
|
||||
|
||||
// ---- CORD-05 invite bundle key --------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.cord02Community
|
||||
|
||||
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.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConcordCommunityFactoryTest {
|
||||
private val owner = NostrSignerInternal(KeyPair())
|
||||
|
||||
@Test
|
||||
fun createsSelfCertifyingCommunityWithGenesisEditions() =
|
||||
runTest {
|
||||
val community =
|
||||
ConcordCommunityFactory.create(
|
||||
ownerSigner = owner,
|
||||
name = "Nostrichs",
|
||||
createdAt = 1_700_000_000L,
|
||||
description = "a cozy place",
|
||||
relays = listOf("wss://relay.example"),
|
||||
)
|
||||
|
||||
// community_id is the self-certifying commitment to owner + salt
|
||||
assertContentEquals(
|
||||
ConcordKeyDerivation.communityId(owner.pubKey.hexToByteArray(), community.ownerSalt),
|
||||
community.communityId,
|
||||
)
|
||||
|
||||
// Two genesis wraps, both authored by the Control Plane address.
|
||||
assertEquals(2, community.genesisWraps.size)
|
||||
community.genesisWraps.forEach {
|
||||
assertEquals(ConcordStreamEnvelope.KIND_WRAP, it.kind)
|
||||
assertEquals(community.controlPlane.publicKeyHex, it.pubKey)
|
||||
}
|
||||
|
||||
// Genesis wraps open with plaintext (20014) seals, authored by the owner.
|
||||
val opened = community.genesisWraps.map { ConcordStreamEnvelope.open(it, community.controlPlane) }
|
||||
opened.forEach {
|
||||
assertEquals(ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT, it.sealKind)
|
||||
assertEquals(owner.pubKey, it.author)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genesisFoldsToLiveCommunityStateWithGeneralChannelAndOwnerAuthority() =
|
||||
runTest {
|
||||
val community =
|
||||
ConcordCommunityFactory.create(owner, name = "Gamers", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
|
||||
val state = ConcordCommunityState.fold(community.genesisEditions, community.ownerPubKey)
|
||||
|
||||
assertEquals("Gamers", state.metadata?.name)
|
||||
assertEquals(listOf("wss://r.example"), state.metadata?.relays)
|
||||
|
||||
val general = state.channels[community.generalChannelIdHex]
|
||||
assertNotNull(general)
|
||||
assertEquals(ConcordCommunityFactory.GENERAL_CHANNEL_NAME, general.definition.name)
|
||||
assertFalse(general.definition.private)
|
||||
|
||||
// The owner is supreme from genesis; no channels are private, none deleted.
|
||||
assertTrue(state.authority.isOwner(owner.pubKey))
|
||||
assertEquals(0L, state.authority.rank(owner.pubKey))
|
||||
assertFalse(state.dissolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentCommunitiesFromSameOwnerHaveDistinctIds() =
|
||||
runTest {
|
||||
val a = ConcordCommunityFactory.create(owner, "A", 1L)
|
||||
val b = ConcordCommunityFactory.create(owner, "B", 1L)
|
||||
// distinct salts ⇒ distinct ids (one owner, many communities)
|
||||
assertFalse(a.communityIdHex == b.communityIdHex)
|
||||
assertFalse(a.communityRoot.toHexKey() == b.communityRoot.toHexKey())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user