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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-09 22:20:51 +00:00
parent 06fa80da65
commit b0e3594eaa
3 changed files with 292 additions and 0 deletions
@@ -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<Array<String>> =
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<RekeyBlob>): 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<RekeyBlob> =
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<RekeyBlob>,
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
}
}
}
@@ -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))
}
}
}
@@ -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<RekeyBlob>,
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])
}
}