diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt new file mode 100644 index 0000000000..314bc953cd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.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.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, + /** The same editions as parsed [ControlEdition]s, for immediate local folding. */ + val genesisEditions: List, +) { + 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 = 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), + ), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt new file mode 100644 index 0000000000..393e4c4ec6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt @@ -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>(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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index ba9ff93571..9673a26516 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -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 = emptyList(), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt index c261c1d94d..b15b2bc56f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt @@ -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 -------------------------------------------- /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt new file mode 100644 index 0000000000..0c234713ca --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt @@ -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()) + } +}