mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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:
+86
@@ -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
|
||||
}
|
||||
+56
@@ -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)
|
||||
}
|
||||
+110
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user