mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
+140
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user