diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt new file mode 100644 index 0000000000..2aa2eec960 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -0,0 +1,86 @@ +/* + * 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.cord03Channels + +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.firstTagValue +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + +/** + * Chat Plane message binding (CORD-03). + * + * Every Chat Plane rumor — a message, reply, reaction, edit, or delete — commits + * to the channel and epoch it belongs to via `["channel", ]` and + * `["epoch", ]` tags inside the author-signed rumor. Recipients enforce this + * binding ([isBoundTo]) so an event lifted from one channel/epoch can't be + * replayed into another. + */ +object ChannelChat { + const val TAG_CHANNEL = "channel" + const val TAG_EPOCH = "epoch" + + /** Builds the channel/epoch binding tags shared by every Chat Plane rumor. */ + fun bindingTags( + channelId: HexKey, + epoch: Long, + ): Array> = arrayOf(arrayOf(TAG_CHANNEL, channelId), arrayOf(TAG_EPOCH, epoch.toString())) + + /** + * Builds an unsigned kind-9 chat message rumor bound to [channelId]/[epoch]. + * Wrap it for the channel plane with + * [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope] (encrypted + * seal) to publish. + */ + fun message( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + text: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event = + RumorAssembler.assembleRumor( + pubKey = authorPubKey, + createdAt = createdAt, + kind = ConcordKinds.MESSAGE, + tags = bindingTags(channelId, epoch) + extraTags, + content = text, + ) + + /** The channel id a Chat Plane [rumor] is bound to, or null if unbound. */ + fun channelOf(rumor: Event): HexKey? = rumor.tags.firstTagValue(TAG_CHANNEL) + + /** The epoch a Chat Plane [rumor] is bound to, or null if unbound/malformed. */ + fun epochOf(rumor: Event): Long? = rumor.tags.firstTagValue(TAG_EPOCH)?.toLongOrNull() + + /** + * True when [rumor] is bound to exactly [channelId] and [epoch]. Recipients + * must reject any Chat Plane event whose binding does not match the plane it + * arrived on. + */ + fun isBoundTo( + rumor: Event, + channelId: HexKey, + epoch: Long, + ): Boolean = channelOf(rumor) == channelId && epochOf(rumor) == epoch +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeys.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeys.kt new file mode 100644 index 0000000000..65dd178f9c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeys.kt @@ -0,0 +1,56 @@ +/* + * 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.cord03Channels + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ConcordLabels +import com.vitorpamplona.quartz.concord.crypto.GroupKey + +/** + * Channel Chat Plane key derivation (CORD-03). + * + * Both channel types use the same `group_key("concord/channel", secret, + * channel_id, epoch)` derivation; only the secret and epoch differ: + * - **Public** channels derive from the shared `community_root` at the current + * root epoch — every member can derive them, so no key is distributed. + * - **Private** channels derive from their own random `channel_key` at the + * channel's own epoch — the key is delivered on role grant and rotated on + * revocation. + * + * The `channel_id` is folded into the derivation so every channel gets a distinct + * address regardless of the secret source, and it stays constant across + * visibility conversions and epoch rotations. + */ +object ConcordChannelKeys { + /** Public channel: derived from the community root at the root epoch. */ + fun publicChannel( + communityRoot: ByteArray, + channelId: ByteArray, + rootEpoch: Long, + ): GroupKey = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, communityRoot, channelId, rootEpoch) + + /** Private channel: derived from its own channel key at the channel epoch. */ + fun privateChannel( + channelKey: ByteArray, + channelId: ByteArray, + channelEpoch: Long, + ): GroupKey = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, channelKey, channelId, channelEpoch) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt new file mode 100644 index 0000000000..ac202549c6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -0,0 +1,110 @@ +/* + * 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.cord03Channels + +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +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.assertNull +import kotlin.test.assertTrue + +/** + * Full CORD-01+03 vertical slice: two members holding the same community_root + * independently derive a public channel key, and one reads the other's message + * off the shared plane — no key distribution required. + */ +class ChannelChatEndToEndTest { + private val communityRoot = ByteArray(32) { 0x5A } + private val channelId = ByteArray(32) { 0x42 } + private val channelIdHex = channelId.toHexKey() + private val rootEpoch = 0L + + @Test + fun twoMembersShareAPublicChannelWithoutKeyDistribution() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + + // Alice derives the public channel plane and sends a message. + val aliceChannel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + val rumor = ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "gm #general", createdAt = 1_700_000_000L) + val wrap = ConcordStreamEnvelope.wrap(rumor, aliceChannel, alice, encrypted = true) + + // Bob, holding the same community_root, derives the identical plane and reads it. + val bobChannel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + assertEquals(aliceChannel.publicKeyHex, bobChannel.publicKeyHex) + + val opened = ConcordStreamEnvelope.open(wrap, bobChannel) + assertEquals("gm #general", opened.rumor.content) + assertEquals(alice.pubKey, opened.author) + assertTrue(ChannelChat.isBoundTo(opened.rumor, channelIdHex, rootEpoch)) + } + + @Test + fun bindingRejectsCrossChannelAndCrossEpochReplay() { + val rumor = + ChannelChat.message( + authorPubKey = KeyPair().pubKey.toHexKey(), + channelId = channelIdHex, + epoch = 0L, + text = "hi", + createdAt = 1L, + ) + assertTrue(ChannelChat.isBoundTo(rumor, channelIdHex, 0L)) + assertFalse(ChannelChat.isBoundTo(rumor, channelIdHex, 1L)) // wrong epoch + assertFalse(ChannelChat.isBoundTo(rumor, "00".repeat(32), 0L)) // wrong channel + assertEquals(channelIdHex, ChannelChat.channelOf(rumor)) + assertEquals(0L, ChannelChat.epochOf(rumor)) + } + + @Test + fun nonMembersCannotDeriveThePlane() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + val wrap = + ConcordStreamEnvelope.wrap( + ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "secret", 1L), + channel, + alice, + encrypted = true, + ) + + // A different community_root derives a different plane key ⇒ cannot open. + val outsiderPlane = ConcordChannelKeys.publicChannel(ByteArray(32) { 0x01 }, channelId, rootEpoch) + assertNull(ConcordStreamEnvelope.openOrNull(wrap, outsiderPlane)) + } + + @Test + fun epochRotationRotatesTheChannelAddress() { + val e0 = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0) + val e1 = ConcordChannelKeys.publicChannel(communityRoot, channelId, 1) + assertFalse(e0.publicKeyHex == e1.publicKeyHex) + + // A private channel with its own key is distinct from the public one at the same id. + val priv = ConcordChannelKeys.privateChannel(ByteArray(32) { 0x77 }, channelId, 0) + assertFalse(priv.publicKeyHex == e0.publicKeyHex) + } +}