feat(concord): add channel key derivation and chat message binding

CORD-03 Chat Plane vertical slice tying crypto + envelope together:

- ConcordChannelKeys: public (community_root) and private (channel_key) channel
  key derivation, both via group_key with channel_id folded in so each channel
  has a distinct, epoch-rotating address
- ChannelChat: channel/epoch binding tags, a kind-9 message rumor builder, and
  isBoundTo validation so an event can't be replayed across channels/epochs

End-to-end test proves two members holding the same community_root independently
derive the identical public channel plane and one reads the other's message with
no key distribution, non-members can't derive the plane, cross-channel/epoch
replay is rejected, and epoch rotation rotates the address. 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 21:59:05 +00:00
parent 71db306f8d
commit d51d8e6d71
3 changed files with 252 additions and 0 deletions
@@ -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", <id>]` and
* `["epoch", <n>]` 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<Array<String>> = 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<Array<String>> = 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
}
@@ -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)
}
@@ -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)
}
}