From b0e3594eaa47fc89b520232e7f12a89c8fb9f550 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 22:20:51 +0000 Subject: [PATCH] feat(concord): add rekey distribution (CORD-06) Non-ratcheted async key rotation to remove members from a channel or (root scope) the whole community, pinned to Concord v2 (Armada rekey.ts): - RekeyPayload: the 72-byte scope_id||epoch_be8||new_key blob codec - RekeyBlob: per-recipient {locator, wrapped} entry - ConcordRekey: blobFor (locator = recipient pseudonym; wrapped = base64 payload NIP-44-encrypted under the rotator<->recipient pairwise key), kind-3303 rumor tags (scope/newepoch/prevepoch/prevcommit/chunk) and content codec, and findNewKey (recipient computes their locator, matches, decrypts, verifies scope+epoch) with absence == removal Test proves remaining members recover the rotated key while a removed member finds no matching blob, and that the locator is epoch-bound. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/cord06Rekey/ConcordRekey.kt | 140 ++++++++++++++++++ .../quartz/concord/cord06Rekey/RekeyBlob.kt | 68 +++++++++ .../concord/cord06Rekey/ConcordRekeyTest.kt | 84 +++++++++++ 3 files changed, 292 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt new file mode 100644 index 0000000000..60745ca6c9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt @@ -0,0 +1,140 @@ +/* + * 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.cord06Rekey + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import kotlinx.serialization.builtins.ListSerializer +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Rekey distribution (CORD-06): non-ratcheted, asynchronous key rotation that + * removes members from a channel (or, in a Refounding, the whole community) while + * keeping the new key secret from those removed. + * + * The rotator publishes a kind-3303 rumor whose content is a JSON array of + * [RekeyBlob]s, one per remaining member. Each blob's `locator` is the recipient's + * pseudonym (public-input HKDF), and its `wrapped` field is the 72-byte + * [RekeyPayload] (base64 → NIP-44 under the rotator↔recipient pairwise key). A + * recipient computes their own locator, finds the matching blob, and decrypts the + * new key; a member with no matching blob across all chunks of a complete rotation + * has been removed. + * + * Pinned to the Concord v2 reference client for interop. + */ +object ConcordRekey { + const val TAG_SCOPE = "scope" + const val TAG_NEWEPOCH = "newepoch" + const val TAG_PREVEPOCH = "prevepoch" + const val TAG_PREVCOMMIT = "prevcommit" + const val TAG_CHUNK = "chunk" + + /** All-zero scope id marks a community_root refounding rather than a channel rekey. */ + val ROOT_SCOPE: ByteArray = ByteArray(32) + + /** + * Builds a rekey blob delivering [newKey] to one recipient. + * + * @param rotatorPrivKey the rotator's private key (their real identity) + * @param rotatorXOnly the rotator's x-only pubkey + * @param recipientXOnly the recipient's x-only pubkey + */ + @OptIn(ExperimentalEncodingApi::class) + fun blobFor( + rotatorPrivKey: ByteArray, + rotatorXOnly: ByteArray, + recipientXOnly: ByteArray, + scopeId: ByteArray, + newEpoch: Long, + newKey: ByteArray, + ): RekeyBlob { + val locator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() + val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey).encode()) + val convKey = Nip44.v2.getConversationKey(rotatorPrivKey, recipientXOnly) + val wrapped = Nip44.v2.encrypt(payloadB64, convKey).encodePayload() + return RekeyBlob(locator, wrapped) + } + + /** The kind-3303 rumor tags for a rekey chunk. */ + fun tags( + scopeId: ByteArray, + newEpoch: Long, + prevEpoch: Long, + prevCommit: HexKey, + chunkIndex: Int, + chunkTotal: Int, + ): Array> = + arrayOf( + arrayOf(TAG_SCOPE, scopeId.toHexKey()), + arrayOf(TAG_NEWEPOCH, newEpoch.toString()), + arrayOf(TAG_PREVEPOCH, prevEpoch.toString()), + arrayOf(TAG_PREVCOMMIT, prevCommit), + arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()), + ) + + /** Serializes a chunk's blobs into the kind-3303 rumor content. */ + fun encodeContent(blobs: List): String = ConcordJson.instance.encodeToString(ListSerializer(RekeyBlob.serializer()), blobs) + + /** Parses a kind-3303 rumor's content back into its blobs, or empty on error. */ + fun decodeContent(content: String): List = + try { + ConcordJson.instance.decodeFromString(ListSerializer(RekeyBlob.serializer()), content) + } catch (_: Exception) { + emptyList() + } + + const val KIND: Int = ConcordKinds.REKEY + + /** + * Finds the recipient's rotated key across the [blobs] of one or more chunks, + * or null if they were removed. Computes the recipient's locator, matches it, + * decrypts under the pairwise key, and verifies the payload's scope and epoch. + * + * @param recipientPrivKey the recipient's private key + * @param recipientXOnly the recipient's x-only pubkey + * @param rotatorXOnly the rotator's x-only pubkey + */ + @OptIn(ExperimentalEncodingApi::class) + fun findNewKey( + blobs: List, + recipientPrivKey: ByteArray, + recipientXOnly: ByteArray, + rotatorXOnly: ByteArray, + scopeId: ByteArray, + newEpoch: Long, + ): ByteArray? { + val myLocator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() + val blob = blobs.firstOrNull { it.locator == myLocator } ?: return null + return try { + val convKey = Nip44.v2.getConversationKey(recipientPrivKey, rotatorXOnly) + val payload = RekeyPayload.decode(Base64.Default.decode(Nip44.v2.decrypt(blob.wrapped, convKey))) ?: return null + if (!payload.scopeId.contentEquals(scopeId) || payload.epoch != newEpoch) return null + payload.newKey + } catch (_: Exception) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt new file mode 100644 index 0000000000..e841183732 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.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.cord06Rekey + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import kotlinx.serialization.Serializable + +/** + * One recipient's entry in a rekey (CORD-06): a [locator] (the recipient's + * pseudonym, so only they know it's for them) and the [wrapped] new key (the + * 72-byte payload, base64'd then NIP-44-encrypted under the rotator↔recipient + * pairwise key). + */ +@Serializable +class RekeyBlob( + val locator: String, + val wrapped: String, +) + +/** + * The 72-byte rekey payload: `scope_id[32] ‖ epoch_be8 ‖ new_key[32]` + * (CORD-06 §2). Fixed-width so a recipient can verify the scope and epoch it + * decrypts to match what they expected before adopting [newKey]. + */ +class RekeyPayload( + val scopeId: ByteArray, + val epoch: Long, + val newKey: ByteArray, +) { + fun encode(): ByteArray { + require(scopeId.size == 32) { "scopeId must be 32 bytes" } + require(newKey.size == 32) { "newKey must be 32 bytes" } + val out = ByteArray(SIZE) + scopeId.copyInto(out, 0) + ConcordKeyDerivation.writeBe64(out, 32, epoch) + newKey.copyInto(out, 40) + return out + } + + companion object { + const val SIZE = 72 + + fun decode(bytes: ByteArray): RekeyPayload? { + if (bytes.size != SIZE) return null + var epoch = 0L + for (i in 0 until 8) epoch = (epoch shl 8) or (bytes[32 + i].toLong() and 0xFF) + return RekeyPayload(bytes.copyOfRange(0, 32), epoch, bytes.copyOfRange(40, 72)) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyTest.kt new file mode 100644 index 0000000000..c0e251dc0a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyTest.kt @@ -0,0 +1,84 @@ +/* + * 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.cord06Rekey + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ConcordRekeyTest { + private val rotator = KeyPair() + private val alice = KeyPair() + private val bob = KeyPair() + private val carol = KeyPair() // removed member + + private val scope = ByteArray(32) { 0x42 } + private val newEpoch = 1L + private val newKey = ByteArray(32) { 0x7E } + + private fun blobFor(recipient: KeyPair) = ConcordRekey.blobFor(rotator.privKey!!, rotator.pubKey, recipient.pubKey, scope, newEpoch, newKey) + + private fun find( + recipient: KeyPair, + blobs: List, + epoch: Long = newEpoch, + ) = ConcordRekey.findNewKey(blobs, recipient.privKey!!, recipient.pubKey, rotator.pubKey, scope, epoch) + + @Test + fun payloadEncodesAndDecodes() { + val decoded = RekeyPayload.decode(RekeyPayload(scope, 42, newKey).encode()) + assertContentEquals(scope, decoded?.scopeId) + assertEquals(42L, decoded?.epoch) + assertContentEquals(newKey, decoded?.newKey) + assertNull(RekeyPayload.decode(ByteArray(70))) // wrong size + } + + @Test + fun remainingMembersGetTheKeyAndRemovedMembersDoNot() { + // Rotator distributes the new key to Alice and Bob, but not Carol. + val blobs = listOf(blobFor(alice), blobFor(bob)) + val content = ConcordRekey.encodeContent(blobs) + val roundTripped = ConcordRekey.decodeContent(content) + + assertContentEquals(newKey, find(alice, roundTripped)) + assertContentEquals(newKey, find(bob, roundTripped)) + assertNull(find(carol, roundTripped)) // no blob for Carol ⇒ removed + } + + @Test + fun wrongEpochDoesNotMatch() { + val blobs = listOf(blobFor(alice)) + assertNull(find(alice, blobs, epoch = 2L)) // locator is epoch-bound + } + + @Test + fun tagsCarryScopeEpochAndChunk() { + val tags = ConcordRekey.tags(scope, newEpoch, prevEpoch = 0, prevCommit = "ab".repeat(32), chunkIndex = 1, chunkTotal = 3) + assertEquals(scope.toHexKey(), tags.first { it[0] == ConcordRekey.TAG_SCOPE }[1]) + assertEquals("1", tags.first { it[0] == ConcordRekey.TAG_NEWEPOCH }[1]) + val chunk = tags.first { it[0] == ConcordRekey.TAG_CHUNK } + assertEquals("1", chunk[1]) + assertEquals("3", chunk[2]) + } +}