Merge pull request #3873 from alexgleason/cord2

feat(concord): staff-held control_root write-gates the Control Plane (CORD-02 §2)
This commit is contained in:
Vitor Pamplona
2026-08-08 12:27:05 -04:00
committed by GitHub
44 changed files with 2272 additions and 217 deletions
@@ -3563,6 +3563,9 @@ class Account(
refreshConcordChannelIndex()
// A revision also bumps when a base-rotation rekey lands; adopt ours if present.
runCatching { concord.drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) }
// A promotion to staff delivers the Control Plane write key inside the Grant
// itself (CORD-04 §3), so the fold that seats the role is also when it arrives.
runCatching { concord.drainConcordStaffGrants() }.onFailure { Log.w("Concord", "staff grant drain failed", it) }
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
// found by re-resolving the invite link we joined through. Rate-limited internally.
runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
@@ -24,20 +24,28 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withControlRoot
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -138,6 +146,10 @@ class AccountConcordActions(
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
// The creator is the founding staff member (CORD-02 §2): it keeps the write
// secret and publishes only the derived pubkey to everyone else.
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = relayUrls,
name = name,
addedAt = TimeUtils.now() * 1000,
@@ -168,6 +180,9 @@ class AccountConcordActions(
rootEpoch = entry.rootEpoch,
name = entry.name,
relays = entry.relays,
// The joiner can never derive the Control Plane address, so the bundle carries
// it (CORD-05 §1). Null on a legacy community, which has none to carry.
controlPk = entry.controlPk,
)
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays)
@@ -245,6 +260,9 @@ class AccountConcordActions(
ownerSalt = bundle.ownerSalt,
root = bundle.communityRoot,
rootEpoch = bundle.rootEpoch,
// Read access to the Control Plane, never write (CORD-05 §1). Absent = the
// community is still pre-split, so we fold it at the legacy address.
controlPk = bundle.controlPk,
relays = bundle.relays,
name = bundle.name,
addedAt = TimeUtils.now() * 1000,
@@ -451,6 +469,24 @@ class AccountConcordActions(
// every client's AuthorityResolver, so a call by someone who doesn't outrank the
// target is simply dropped on fold. Owner-authored calls always take effect.
/**
* The Control Plane keys for a moderation write, or null when this account cannot
* publish there: on a split epoch only `control_root` holders can mint a wrap that
* verifies at the plane's address (CORD-02 §2), and wrapping without the secret
* throws rather than missigning. Rank and key possession can diverge — a freshly
* promoted staffer writes only once their `control_wrap` is adopted (CORD-04 §3),
* and the UI gates on rank — so every moderation verb no-ops through this check
* instead of crashing on a rank-gated action.
*/
private fun controlKeysForWrite(session: ConcordCommunitySession): ControlPlaneKeys? {
val cp = session.controlPlaneKeys()
if (!cp.canWrite) {
Log.w("Concord") { "Control write refused for ${session.entry.id}: control_root not held at epoch ${session.entry.rootEpoch} (CORD-02 §2)" }
return null
}
return cp
}
/** Grant [member] exactly [roleIds] (empty list revokes their roles). */
suspend fun grantConcordRole(
communityId: String,
@@ -459,7 +495,23 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val wrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val cp = controlKeysForWrite(session) ?: return false
// A Grant that first makes its member staff must deliver the control_root in the same
// edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the
// roles carry a Control-writing bit and we hold the secret to hand over.
val wrap =
ConcordModeration.grantWithStaffDelivery(
actor = account.signer,
controlPlane = cp,
communityId = communityId.hexToByteArray(),
member = member,
roleIds = roleIds,
current = session.controlEditions(),
createdAt = TimeUtils.now(),
owner = session.entry.owner,
controlRoot = session.entry.controlRoot?.hexToByteArray(),
epoch = session.entry.rootEpoch,
)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -494,11 +546,11 @@ class AccountConcordActions(
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null
if (author == account.signer.pubKey) return null
val communityId = channel.channelId.communityId
val state =
account.concordSessions
.sessionFor(communityId)
?.state
?.value ?: return null
val session = account.concordSessions.sessionFor(communityId) ?: return null
val state = session.state.value ?: return null
// Rank alone isn't enough on a split epoch: the Grant edition takes the control_root
// (CORD-02 §2), so don't offer an action the verb would refuse.
if (!session.controlPlaneKeys().canWrite) return null
if (state.authority.isOwner(author) || !state.authority.isOwner(account.signer.pubKey)) return null
val adminRoleId =
state.roles.entries
@@ -515,7 +567,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = session.controlPlaneKey()
val cp = controlKeysForWrite(session) ?: return false
val existing =
session.state.value
@@ -530,7 +582,22 @@ class AccountConcordActions(
roleId.toHexKey()
}
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
// Admin carries every management bit, so this Grant makes its member staff: it must
// deliver the control_root alongside the rank (CORD-04 §3), or the new admin holds
// authority it cannot publish under.
val grantWrap =
ConcordModeration.grantWithStaffDelivery(
actor = account.signer,
controlPlane = cp,
communityId = communityId.hexToByteArray(),
member = member,
roleIds = listOf(roleIdHex),
current = session.controlEditions(),
createdAt = TimeUtils.now(),
owner = session.entry.owner,
controlRoot = session.entry.controlRoot?.hexToByteArray(),
epoch = session.entry.rootEpoch,
)
publishConcordWrap(session.entry, grantWrap)
return true
}
@@ -542,7 +609,8 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val grantWrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val cp = controlKeysForWrite(session) ?: return false
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, grantWrap)
return true
}
@@ -568,12 +636,11 @@ class AccountConcordActions(
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null
if (author == account.signer.pubKey) return null
val communityId = channel.channelId.communityId
val authority =
account.concordSessions
.sessionFor(communityId)
?.state
?.value
?.authority ?: return null
val session = account.concordSessions.sessionFor(communityId) ?: return null
val authority = session.state.value?.authority ?: return null
// Rank alone isn't enough on a split epoch: the banlist edition takes the control_root
// (CORD-02 §2), so don't offer an action the verb would refuse.
if (!session.controlPlaneKeys().canWrite) return null
if (authority.isOwner(author)) return null
// The owner short-circuits rather than going through canActOn: canActOn starts at
// hasPermission, which is false while banned, and a rogue BAN holder *can* currently put
@@ -590,7 +657,8 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val wrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val cp = controlKeysForWrite(session) ?: return false
val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -602,7 +670,8 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val wrap = ConcordModeration.unban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val cp = controlKeysForWrite(session) ?: return false
val wrap = ConcordModeration.unban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -636,12 +705,16 @@ class AccountConcordActions(
if (!iCanBan) return false
val removedLower = removed.mapTo(HashSet()) { it.lowercase() }
if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false
// A Refounding writes the current plane (the pre-rotation bans) and the new one (the
// compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A
// rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery.
val cp = controlKeysForWrite(session) ?: return false
// 1. Ban the removed members on the current Control Plane so the compacted snapshot —
// and thus the new epoch — carries the ban. publishConcordWrap folds it in locally
// first, so each subsequent edition chains onto the updated banlist head.
for (target in removedLower) {
val banWrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val banWrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, banWrap)
}
@@ -667,16 +740,27 @@ class AccountConcordActions(
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
val entry = session.entry
val newRoot = RandomInstance.bytes(32)
// A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2),
// so a demoted staffer's retained secret dies with the epoch — and a legacy community
// upgrades to the split as a side effect of its next ban (CORD-06 §3).
val newControlRoot = RandomInstance.bytes(32)
// The staff set the new secret goes to: the owner plus everyone holding a
// Control-writing bit (CORD-04 §3). They get the 136-byte blob, every other
// recipient the 104-byte one carrying the pubkey alone. (The builder mints a
// blob per recipient, so staff who aren't recipients are simply never reached.)
val staff = authority.staffMembers()
val build =
ConcordActions.buildRefounding(
rotatorSigner = account.signer,
communityId = communityId,
priorRoot = entry.root.hexToByteArray(),
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = entry.rootEpoch,
priorControlWraps = session.controlPlaneWraps(),
priorControlKey = session.controlPlaneKey(),
priorControlKeys = cp,
recipientsXOnly = recipients,
staffXOnly = staff,
createdAt = TimeUtils.now(),
)
@@ -690,7 +774,7 @@ class AccountConcordActions(
// 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and
// re-folds the compacted Control Plane (with the ban), dropping the removed members.
adoptConcordRoot(entry, newRoot, build.newEpoch)
adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
return true
}
@@ -710,9 +794,14 @@ class AccountConcordActions(
entry: ConcordCommunityListEntry,
newRoot: ByteArray,
newEpoch: Long,
newControlPk: ByteArray? = null,
newControlRoot: ByteArray? = null,
) {
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch }
// The epoch we're leaving is banked with the address it was folded at, so its Control
// Plane stays subscribable for the anti-rollback floor (a split epoch's address can
// never be re-derived, only remembered — CORD-02 §2).
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }
val next =
ConcordCommunityListEntry(
id = entry.id,
@@ -720,6 +809,11 @@ class AccountConcordActions(
ownerSalt = entry.ownerSalt,
root = newRoot.toHexKey(),
rootEpoch = newEpoch,
// A rotation that delivered no control material is a legacy, pre-split one
// (CORD-06 §3): the new epoch keeps folding at the legacy address, and the
// stale prior-epoch values must NOT be carried into it.
controlPk = newControlPk?.toHexKey(),
controlRoot = newControlRoot?.toHexKey(),
heldRoots = held,
privateChannels = entry.privateChannels,
relays = entry.relays,
@@ -769,6 +863,7 @@ class AccountConcordActions(
wraps = wraps,
baseRekey = session.nextBaseRekeyKey(),
recipientSigner = account.signer,
communityId = entry.id,
priorRoot = entry.root.hexToByteArray(),
rootEpoch = entry.rootEpoch,
) ?: continue
@@ -779,7 +874,62 @@ class AccountConcordActions(
// who has themselves been banned could still rotate the whole community.
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
if (!authorized) continue
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
}
}
/**
* Adopt a `control_root` delivered to us by a staff-making Grant (CORD-04 §3): the
* promoting edition carries the secret in `control_wrap`, NIP-44-encrypted under the
* granter↔member pairwise key, so promotion and key delivery are one signed edition
* with nothing separate to watch an inbox for.
*
* Adoption is gated twice and fails closed both times. The secret is adopted only if
* it derives to exactly the `control_pk` we already hold for the named epoch — a
* garbage wrap is attributable griefing, nothing worse — and only from a Grant our own
* fold honors, so a rogue cannot feed us a key by minting an edition nobody accepts.
* The epoch check matters because compaction re-wraps a Grant head verbatim across
* Refoundings, so a folded head can legitimately carry a wrap minted for a prior epoch.
*
* Idempotent: once the entry holds the secret there is nothing to adopt. Runs on the
* revision tick, like the rekey drain.
*/
internal suspend fun drainConcordStaffGrants() {
if (!account.isWriteable()) return
val me = account.signer.pubKey.lowercase()
for (session in account.concordSessions.sessions()) {
val entry = session.entry
// Already staff at this epoch, or a legacy community with no split to join.
val heldControlPk = entry.controlPk
if (entry.controlRoot != null || heldControlPk == null) continue
val state = session.state.value ?: continue
// Only a Grant our fold honors can deliver: an unauthorized edition hands us nothing.
if (!state.authority.isStaff(me)) continue
val myGrantCoordinate =
ConcordKeyDerivation
.grantCoordinate(entry.id.hexToByteArray(), me.hexToByteArray())
.toHexKey()
val delivered =
session
.controlEditions()
.filter { it.entityKind == ControlEntityKind.GRANT && it.entityIdHex == myGrantCoordinate }
// Newest first: a re-issued Grant (a lost key, a head superseded before we
// fetched it) carries the fresher wrap.
.sortedByDescending { it.version }
.firstNotNullOfOrNull { edition ->
val wrap = ConcordJson.decodeOrNull<GrantEntity>(edition.content)?.controlWrap ?: return@firstNotNullOfOrNull null
val opened = ControlRootWrap.openOrNull(wrap, account.signer, edition.author) ?: return@firstNotNullOfOrNull null
if (opened.epoch != entry.rootEpoch) return@firstNotNullOfOrNull null
// Fails closed: a secret that doesn't derive to the pk we hold is dropped,
// never adopted — we will not split ourselves off from the plane's readers.
if (!ControlRootWrap.derivesTo(opened.controlRoot, entry.id.hexToByteArray(), entry.rootEpoch, heldControlPk)) return@firstNotNullOfOrNull null
opened.controlRoot
} ?: continue
account.sendMyPublicAndPrivateOutbox(
account.concordChannelList.follow(entry.withControlRoot(delivered.toHexKey())),
)
}
}
@@ -859,8 +1009,9 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
val wrap = ConcordModeration.editMetadata(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -876,9 +1027,10 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
val channelId = RandomInstance.bytes(32)
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -891,6 +1043,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
// Carry the standing definition forward and change only the name. A ChannelEntity built from
// scratch defaults `private` and `voice` to false, so renaming a private channel used to
// publish an edition declaring it PUBLIC — and a voice channel became a text channel.
@@ -900,7 +1053,7 @@ class AccountConcordActions(
?.get(channelIdHex)
?.definition
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false)
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -913,6 +1066,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForWrite(session) ?: return false
// Same as rename: preserve the standing flags so a tombstone does not also silently
// reclassify the channel it retires.
val standing =
@@ -921,7 +1075,7 @@ class AccountConcordActions(
?.get(channelIdHex)
?.definition
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true)
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -167,11 +167,15 @@ fun ConcordChannelListScreen(
// Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the
// fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer.
// Rank alone isn't enough on a split epoch: publishing any Control edition also takes the
// control_root (CORD-02 §2), which a freshly promoted staffer may not hold yet (CORD-04 §3),
// so the affordance waits for the key too.
val canManageChannels =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS)
} == true
} == true &&
session?.controlPlaneKeys()?.canWrite == true
// channelIdHex == null → create; else → rename that channel.
var channelEditor by remember { mutableStateOf<ConcordChannelEditor?>(null) }
@@ -234,11 +238,13 @@ fun ConcordChannelListScreen(
}
},
actions = {
// Rank + the Control write key (CORD-02 §2), like [canManageChannels] above.
val canEdit =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA)
} == true
} == true &&
session?.controlPlaneKeys()?.canWrite == true
IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) {
SymbolIcon(symbol = MaterialSymbols.Group, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_members_title))
@@ -150,12 +150,14 @@ object ConcordChannelCommands {
ctx: Context,
sc: StoredCommunity,
): ConcordCommunityState {
val controlPlane = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
val controlPlane = ConcordCommands.controlPlaneKeysFor(sc)
val relays = ConcordCommands.relaysFor(ctx, sc)
// The relays gate the plane's kind-1059 behind NIP-42 as the derived stream key — register
// it so the drain's AUTH challenge is answered as the control plane, not the account.
ctx.registerConcordStreamKeys(relays, listOf(controlPlane.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
// The relays gate the plane's kind-1059 behind NIP-42 as the stream key — register it so
// the drain's AUTH challenge is answered as the control plane, not the account. On a split
// epoch only staff hold that secret (CORD-02 §2); a plain member registers nothing and
// relies on the relay serving the plane unauthenticated.
ctx.registerConcordStreamKeys(relays, listOfNotNull(controlPlane.signer?.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.address)) }, pendingOnAuthRequired = true).map { it.second }
return ConcordActions.foldCommunity(wraps, controlPlane, sc.owner)
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -119,6 +120,10 @@ object ConcordCommands {
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
// The creator is the founding staff member: it keeps the write secret and
// publishes the pubkey to everyone else (CORD-02 §2).
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
generalChannelId = community.generalChannelIdHex,
relays = relays,
),
@@ -179,6 +184,14 @@ object ConcordCommands {
val imported =
entries.map { e ->
val prior = existing[e.id]
// Control key material is per-epoch (CORD-02 §2): a stored value may only
// backstop a list entry from the SAME epoch (e.g. another client republished
// the list without the extension fields). Across a rotation the old pair is
// stale — a prior-epoch control_root would derive a wrong address entirely,
// and a prior-epoch control_pk would shadow a legacy rotation's address — so
// it must never be carried forward (the invariant adoption enforces with its
// derive-check, which this path has no way to run).
val priorSameEpoch = prior?.takeIf { it.rootEpoch == e.rootEpoch }
store.upsert(
StoredCommunity(
name = e.name.ifBlank { prior?.name ?: "" },
@@ -187,15 +200,23 @@ object ConcordCommands {
ownerSalt = e.ownerSalt,
root = e.root,
rootEpoch = e.rootEpoch,
// Carried straight from the list entry: the Control Plane address is
// delivered, never derivable (CORD-02 §2), and the write secret only
// rides the list when this account is staff. Both blank on a legacy
// community, which keeps its old single-key plane.
controlPk = e.controlPk ?: priorSameEpoch?.controlPk ?: "",
controlRoot = e.controlRoot ?: priorSameEpoch?.controlRoot ?: "",
generalChannelId = prior?.generalChannelId ?: "",
relays = e.relays,
heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key) },
heldRoots = e.heldRoots.map { StoredHeldRoot(it.epoch, it.key, it.controlPk ?: "") },
),
)
mapOf(
"name" to e.name,
"community_id" to e.id,
"root_epoch" to e.rootEpoch,
"control_pk" to (e.controlPk ?: ""),
"staff" to (e.controlRoot != null),
"held_roots" to e.heldRoots.map { mapOf("epoch" to it.epoch, "root" to it.key) },
)
}
@@ -216,7 +237,9 @@ object ConcordCommands {
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle)
Context.open(dataDir).use { ctx ->
ctx.prepare()
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays)
// The joiner cannot derive the Control Plane address, so the invite carries it
// (CORD-05 §1); omitted for a legacy community, which has none to carry.
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null })
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays)
val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc))
RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it }
@@ -257,6 +280,9 @@ object ConcordCommands {
ownerSalt = bundle.ownerSalt,
root = bundle.communityRoot,
rootEpoch = bundle.rootEpoch,
// Read access to the Control Plane, never write (CORD-05 §1). Absent = the
// community is still pre-split and folds at the legacy address.
controlPk = bundle.controlPk ?: "",
relays = bundle.relays,
),
)
@@ -276,6 +302,20 @@ object ConcordCommands {
sc: StoredCommunity,
): Set<NormalizedRelayUrl> = normalize(sc.relays).ifEmpty { ctx.outboxRelays() }
/**
* The Control Plane keys for [sc] as this account holds them (CORD-02 §5): staff
* (write key held), member (address held, read-only), or legacy (pre-split, keyed
* by the `community_root` alone).
*/
fun controlPlaneKeysFor(sc: StoredCommunity) =
ConcordActions.controlPlaneKeys(
communityRoot = sc.root.hexToByteArray(),
communityId = sc.communityId.hexToByteArray(),
rootEpoch = sc.rootEpoch,
controlPk = sc.controlPk.ifBlank { null },
controlRoot = sc.controlRoot.ifBlank { null },
)
fun notFound(handle: String): Int {
Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`")
return 1
@@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.RandomInstance
@@ -93,6 +93,7 @@ object ConcordModCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val (cp, editions) = load(ctx, sc)
writeGuard(cp)?.let { return it }
val roleId = RandomInstance.bytes(32)
val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire())
val wrap = ConcordModeration.defineRole(ctx.signer, cp, roleId, role, editions, TimeUtils.now(), owner = sc.owner)
@@ -119,7 +120,23 @@ object ConcordModCommands {
ctx.prepare()
val member = ctx.requireUserHex(userRef)
val (cp, editions) = load(ctx, sc)
val wrap = ConcordModeration.grant(ctx.signer, cp, sc.communityId.hexToByteArray(), member, listOf(roleId), editions, TimeUtils.now(), owner = sc.owner)
writeGuard(cp)?.let { return it }
// A Grant that first makes its member staff must carry the write secret in the same
// edition (CORD-04 §3); ConcordModeration wraps it pairwise when the granted roles
// hold a Control-writing bit and we hold the secret to deliver.
val wrap =
ConcordModeration.grantWithStaffDelivery(
actor = ctx.signer,
controlPlane = cp,
communityId = sc.communityId.hexToByteArray(),
member = member,
roleIds = listOf(roleId),
current = editions,
createdAt = TimeUtils.now(),
owner = sc.owner,
controlRoot = sc.controlRoot.ifBlank { null }?.hexToByteArray(),
epoch = sc.rootEpoch,
)
val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc))
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + RawEventSupport.ackFields(ack))
@@ -154,6 +171,7 @@ object ConcordModCommands {
ctx.prepare()
val member = ctx.requireUserHex(userRef)
val (cp, editions) = load(ctx, sc)
writeGuard(cp)?.let { return it }
val cid = sc.communityId.hexToByteArray()
val wrap =
if (ban) {
@@ -168,20 +186,34 @@ object ConcordModCommands {
}
}
/** Drain the control plane and return its key + current editions to chain onto. */
/** Drain the control plane and return its keys + current editions to chain onto. */
private suspend fun load(
ctx: Context,
sc: StoredCommunity,
): Pair<GroupKey, List<ControlEdition>> {
val cp = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
): Pair<ControlPlaneKeys, List<ControlEdition>> {
val cp = ConcordCommands.controlPlaneKeysFor(sc)
val relays = ConcordCommands.relaysFor(ctx, sc)
// Concord relays serve the plane's kind-1059 only to a connection AUTHed as the derived
// stream key — register the control key so the drain isn't refused (else the fold is empty).
ctx.registerConcordStreamKeys(relays, listOf(cp.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
// Concord relays serve the plane's kind-1059 only to a connection AUTHed as the stream
// key — register it so the drain isn't refused (else the fold is empty). On a split epoch
// that secret is staff-only (CORD-02 §2), and a member simply has nothing to register.
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
return cp to ConcordActions.controlEditions(wraps, cp)
}
/**
* Refuses a moderation command that this account cannot publish: on a split epoch
* only `control_root` holders can mint a wrap the plane accepts (CORD-02 §2), so a
* member would otherwise sign an edition every relay and reader drops. Possession is
* a spam gate, never authority holding the key still does not make the action
* honored, which the Roster decides at fold (CORD-04 §5).
*/
private fun writeGuard(cp: ControlPlaneKeys): Int? {
if (cp.canWrite) return null
Output.error("forbidden", "this account holds no control_root for the community, so it cannot publish Control Plane editions (CORD-02 §2) — ask a staff member to grant you a Control-writing role")
return 1
}
private fun permByName(name: String): Int? =
when (name.uppercase()) {
"MANAGE_ROLES" -> ConcordPermissions.MANAGE_ROLES
@@ -37,6 +37,12 @@ data class StoredCommunity(
val ownerSalt: String = "",
val root: String = "",
val rootEpoch: Long = 0,
// The Control Plane signer's pubkey at [rootEpoch] (CORD-02 §2): read access, never write.
// Blank = a legacy, pre-split community, whose Control Plane is keyed the old way.
val controlPk: String = "",
// The staff write key at [rootEpoch], held only when this account is the owner or staff
// (CORD-02 §2). Blank for a regular member, who can read the plane but not publish to it.
val controlRoot: String = "",
val generalChannelId: String = "",
val relays: List<String> = emptyList(),
// Past access roots kept per epoch (CORD-06 Refounding rotates the root). Lets `read --epoch <n>`
@@ -48,6 +54,8 @@ data class StoredCommunity(
data class StoredHeldRoot(
val epoch: Long = 0,
val root: String = "",
/** That epoch's Control Plane address; blank for a legacy, pre-split epoch (CORD-02 §5). */
val controlPk: String = "",
)
/**
@@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
import com.vitorpamplona.quartz.concord.cord06Rekey.RefoundingBuild
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -89,12 +90,52 @@ data class HistoricalChannelPlane(
object ConcordActions {
// ---- plane key derivation -------------------------------------------------
/**
* The **legacy** Control Plane group key (pre-split epochs, CORD-06 §3), which
* doubles as the split epochs' *read* key derivation. For anything that opens
* or writes the Control Plane, prefer [controlPlaneKeys].
*/
fun controlPlane(
communityRoot: ByteArray,
communityId: ByteArray,
rootEpoch: Long,
): GroupKey = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch)
/**
* The Control Plane keys as this account holds them (CORD-02 §5): staff when
* [controlRoot] is held, read-only member when only [controlPk] is, and the
* legacy single-key plane when neither (a pre-split epoch).
*/
fun controlPlaneKeys(
communityRoot: ByteArray,
communityId: ByteArray,
rootEpoch: Long,
controlPk: HexKey? = null,
controlRoot: HexKey? = null,
): ControlPlaneKeys = ControlPlaneKeys.of(communityRoot, communityId, rootEpoch, controlPk, controlRoot)
/** The Control Plane keys described by a joined-list [entry]. */
fun controlPlaneKeysFor(entry: ConcordCommunityListEntry): ControlPlaneKeys =
controlPlaneKeys(
entry.root.hexToByteArray(),
entry.id.hexToByteArray(),
entry.rootEpoch,
entry.controlPk,
entry.controlRoot,
)
/**
* The Control Plane's stream address for a subscription: the held `control_pk`
* on a split epoch, else the legacy derived address. Cheaper than
* [controlPlaneKeys] when only the address is needed.
*/
fun controlPlaneAddress(
communityRoot: ByteArray,
communityId: ByteArray,
rootEpoch: Long,
controlPk: HexKey?,
): HexKey = controlPk?.lowercase() ?: controlPlane(communityRoot, communityId, rootEpoch).publicKeyHex
fun publicChannel(
communityRoot: ByteArray,
channelId: ByteArray,
@@ -178,7 +219,7 @@ object ConcordActions {
/** Opens the control-plane [wraps] into their [ControlEdition]s (drops any that don't open/parse). */
fun controlEditions(
wraps: List<Event>,
controlPlane: GroupKey,
controlPlane: ControlPlaneKeys,
): List<ControlEdition> =
wraps.mapNotNull { wrap ->
ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromRumor(it.rumor) }
@@ -187,7 +228,7 @@ object ConcordActions {
/** Opens the control-plane [wraps] and folds them into the live community state. */
fun foldCommunity(
wraps: List<Event>,
controlPlane: GroupKey,
controlPlane: ControlPlaneKeys,
ownerPubKey: HexKey,
): ConcordCommunityState = ConcordCommunityState.fold(controlEditions(wraps, controlPlane), ownerPubKey)
@@ -360,7 +401,12 @@ object ConcordActions {
// ---- invites --------------------------------------------------------------
/** Builds a [CommunityInvite] from a freshly created (or joined) community's public info. */
/**
* Builds a [CommunityInvite] from a freshly created (or joined) community's
* public info. [controlPk] is the Control Plane's signer pubkey at [rootEpoch]
* (CORD-05 §1) read access for the joiner, never write; null only for a
* legacy, pre-split community.
*/
fun inviteFor(
communityIdHex: HexKey,
ownerPubKey: HexKey,
@@ -369,6 +415,7 @@ object ConcordActions {
rootEpoch: Long,
name: String,
relays: List<String>,
controlPk: HexKey? = null,
): CommunityInvite =
CommunityInvite(
communityId = communityIdHex,
@@ -376,6 +423,7 @@ object ConcordActions {
ownerSalt = ownerSaltHex,
communityRoot = communityRootHex,
rootEpoch = rootEpoch,
controlPk = controlPk,
relays = relays,
name = name,
)
@@ -427,8 +475,18 @@ object ConcordActions {
nowMs: Long = TimeUtils.nowMillis(),
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token, nowMs)
/** Derives the control plane described by a redeemed [invite] so the joiner can read it. */
fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch)
/**
* The Control Plane keys described by a redeemed [invite] so the joiner can
* read it: the bundle's `control_pk` on a split community, the legacy plane
* when absent (CORD-05 §1). Never a writer an invite delivers no secret.
*/
fun controlPlaneFor(invite: CommunityInvite): ControlPlaneKeys =
controlPlaneKeys(
invite.communityRoot.hexToByteArray(),
invite.communityId.hexToByteArray(),
invite.rootEpoch,
controlPk = invite.controlPk,
)
// ---- guestbook (CORD-02 §5) ----------------------------------------------
@@ -468,19 +526,23 @@ object ConcordActions {
/**
* Builds a whole-community Refounding (CORD-06 §3): the compacted Control Plane
* re-sealed under [newRoot] plus the base-rotation rekey blobs delivering
* [newRoot] to [recipientsXOnly]. Pure the caller sources the recipient set
* and owns publish + persistence.
* re-sealed at the new epoch's split Control address plus the base-rotation
* rekey blobs delivering [newRoot] + the new `control_pk` to [recipientsXOnly]
* the [staffXOnly] subset also receiving [newControlRoot] (CORD-06 §1). Pure
* the caller sources the recipient and staff sets (the folded Roster's
* `staffMembers()`, CORD-04 §3) and owns publish + persistence.
*/
suspend fun buildRefounding(
rotatorSigner: NostrSigner,
communityId: HexKey,
priorRoot: ByteArray,
newRoot: ByteArray,
newControlRoot: ByteArray,
rootEpoch: Long,
priorControlWraps: List<Event>,
priorControlKey: GroupKey,
priorControlKeys: ControlPlaneKeys,
recipientsXOnly: List<HexKey>,
staffXOnly: Set<HexKey>,
createdAt: Long,
): RefoundingBuild =
ConcordRefounding.build(
@@ -488,24 +550,29 @@ object ConcordActions {
communityId = communityId.hexToByteArray(),
priorRoot = priorRoot,
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = rootEpoch,
priorControlWraps = priorControlWraps,
priorControlKey = priorControlKey,
priorControlKeys = priorControlKeys,
recipientsXOnly = recipientsXOnly,
staffXOnly = staffXOnly,
createdAt = createdAt,
)
/**
* Receives an inbound base rotation for the member behind [recipientSigner]:
* finds the delivered new root across the buffered kind-3303 [wraps], verifying
* scope, epoch and continuity against the [priorRoot] the member holds. Returns
* the new root + rotator (for the caller to authorize) or null if not re-keyed.
* scope, epoch and continuity against the [priorRoot] the member holds and,
* on a staff blob, that the delivered `control_root` derives to the delivered
* `control_pk` (CORD-06 §1). Returns the new root + Control keys + rotator
* (for the caller to authorize) or null if not re-keyed.
*/
suspend fun openBaseRekey(
wraps: List<Event>,
baseRekey: GroupKey,
recipientSigner: NostrSigner,
communityId: HexKey,
priorRoot: ByteArray,
rootEpoch: Long,
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, priorRoot, rootEpoch)
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch)
}
@@ -25,14 +25,16 @@ import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -97,7 +99,7 @@ object ConcordModeration {
private suspend fun wrap(
actor: NostrSigner,
controlPlane: GroupKey,
controlPlane: ControlPlaneKeys,
kind: ControlEntityKind,
entityId: ByteArray,
version: Long,
@@ -116,7 +118,7 @@ object ConcordModeration {
*/
suspend fun defineRole(
actor: NostrSigner,
controlPlane: GroupKey,
controlPlane: ControlPlaneKeys,
roleId: ByteArray,
role: RoleEntity,
current: List<ControlEdition>,
@@ -138,7 +140,7 @@ object ConcordModeration {
*/
suspend fun defineChannel(
actor: NostrSigner,
controlPlane: GroupKey,
controlPlane: ControlPlaneKeys,
channelId: ByteArray,
channel: ChannelEntity,
current: List<ControlEdition>,
@@ -159,7 +161,7 @@ object ConcordModeration {
*/
suspend fun editMetadata(
actor: NostrSigner,
controlPlane: GroupKey,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
metadata: MetadataEntity,
current: List<ControlEdition>,
@@ -172,10 +174,18 @@ object ConcordModeration {
return wrap(actor, controlPlane, ControlEntityKind.METADATA, communityId, version, prev, content, createdAt, citation)
}
/** Grants [member] exactly [roleIds] (replaces their prior grant). Empty list revokes all roles. */
/**
* Grants [member] exactly [roleIds] (replaces their prior grant). Empty list revokes all roles.
*
* A Grant that first makes its member **staff** must deliver the current
* `control_root` in the same edition (CORD-04 §3): pass [controlWrap] built with
* [ControlRootWrap.build] for the current epoch. A current staffer may also
* re-issue a Grant with a fresh wrap to re-deliver (a lost key, a superseded
* head). Leave null for a non-staff grant, a revoke, or a legacy community.
*/
suspend fun grant(
actor: NostrSigner,
controlPlane: GroupKey,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
member: HexKey,
roleIds: List<String>,
@@ -183,17 +193,63 @@ object ConcordModeration {
createdAt: Long,
citation: AuthorityCitation? = null,
owner: HexKey,
controlWrap: String? = null,
): Event {
val entityId = ConcordKeyDerivation.grantCoordinate(communityId, member.hexToByteArray())
val (version, prev) = versioning(current, entityId, owner)
val content = ConcordJson.instance.encodeToString(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds))
val content = ConcordJson.instance.encodeToString(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds, controlWrap = controlWrap))
return wrap(actor, controlPlane, ControlEntityKind.GRANT, entityId, version, prev, content, createdAt, citation)
}
/**
* [grant], deciding the staff delivery for the caller: when [roleIds] hands the
* member any Control-writing bit ([ConcordPermissions.STAFF_BITS]) and we hold the
* [controlRoot] to deliver, the edition carries a `control_wrap` fresh for [epoch]
* (CORD-04 §3). A non-staff grant, a revoke, a legacy community, or a granter who
* does not hold the secret all produce a plain Grant.
*
* The role bits are read off the same authority-gated fold the readers use, so a
* role a reader would drop never triggers a delivery and a role we cannot resolve
* yet (its edition unseen) conservatively doesn't either, which the spec's re-issue
* path covers: any current staffer MAY re-issue a Grant with a fresh wrap.
*/
suspend fun grantWithStaffDelivery(
actor: NostrSigner,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
member: HexKey,
roleIds: List<String>,
current: List<ControlEdition>,
createdAt: Long,
citation: AuthorityCitation? = null,
owner: HexKey,
controlRoot: ByteArray?,
epoch: Long,
): Event {
val wrap =
if (controlRoot != null && makesStaff(roleIds, current, owner)) {
ControlRootWrap.build(actor, member, epoch, controlRoot)
} else {
null
}
return grant(actor, controlPlane, communityId, member, roleIds, current, createdAt, citation, owner, wrap)
}
/** True when any of [roleIds] resolves to a role carrying a Control-writing bit (CORD-04 §3). */
fun makesStaff(
roleIds: List<String>,
current: List<ControlEdition>,
owner: HexKey,
): Boolean {
if (roleIds.isEmpty()) return false
val roles = AuthorityResolver.resolve(current, owner).roles()
return roleIds.any { roles[it]?.permissionBits()?.hasAny(ConcordPermissions.STAFF_BITS) == true }
}
/** Adds [member] to the banlist (union with the current head). */
suspend fun ban(
actor: NostrSigner,
controlPlane: GroupKey,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
member: HexKey,
current: List<ControlEdition>,
@@ -205,7 +261,7 @@ object ConcordModeration {
/** Removes [member] from the banlist. */
suspend fun unban(
actor: NostrSigner,
controlPlane: GroupKey,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
member: HexKey,
current: List<ControlEdition>,
@@ -231,7 +287,7 @@ object ConcordModeration {
private suspend fun setBanlist(
actor: NostrSigner,
controlPlane: GroupKey,
controlPlane: ControlPlaneKeys,
communityId: ByteArray,
banned: Set<HexKey>,
current: List<ControlEdition>,
@@ -72,17 +72,20 @@ object ConcordSubscriptionPlanner {
entries.flatMap { e ->
val communityId = e.id.hexToByteArray()
val relays = normalize(e.relays)
val cp = ConcordActions.controlPlane(e.root.hexToByteArray(), communityId, e.rootEpoch)
// The address is the held `control_pk` on a split epoch and the legacy derivation
// otherwise (CORD-02 §5) — a member can never derive the former, so it is read off
// the entry, per epoch, exactly as it was delivered.
val cp = ConcordActions.controlPlaneKeysFor(e)
val historical =
e.heldRoots
.filter { it.epoch < e.rootEpoch }
.sortedByDescending { it.epoch }
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
.mapNotNull { held ->
val key = runCatching { ConcordActions.controlPlane(held.key.hexToByteArray(), communityId, held.epoch) }.getOrNull() ?: return@mapNotNull null
ConcordPlaneSub(channelId = null, pubKeyHex = key.publicKeyHex, relays = relays)
val keys = runCatching { ConcordActions.controlPlaneKeys(held.key.hexToByteArray(), communityId, held.epoch, held.controlPk, held.controlRoot) }.getOrNull() ?: return@mapNotNull null
ConcordPlaneSub(channelId = null, pubKeyHex = keys.address, relays = relays)
}
listOf(ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = relays)) + historical
listOf(ConcordPlaneSub(channelId = null, pubKeyHex = cp.address, relays = relays)) + historical
}
/**
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -39,6 +40,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlin.concurrent.Volatile
/**
* A validated inner chat rumor emitted by a session: its parent [communityId] and
@@ -96,14 +98,33 @@ enum class ConcordIngestOutcome {
* [ConcordActions]/[ConcordPlaneRegistry] helpers.
*/
class ConcordCommunitySession(
val entry: ConcordCommunityListEntry,
entry: ConcordCommunityListEntry,
val myPubKey: HexKey,
private val onRumor: ConcordRumorSink = { _, _, _, _ -> },
) {
/**
* The joined-list entry this session projects. Replaced in place only ever by
* [adoptControlMaterial], and only within one epoch because the Control Plane
* write key can arrive long after the session was built (CORD-04 §3). A change
* that moves the *planes* (a Refounding) rebuilds the session instead.
*
* Volatile because the ingest path, the UI and the adopting drain are different
* threads: the write happens under [lock], but readers take it unsynchronized.
*/
@Volatile
var entry: ConcordCommunityListEntry = entry
private set
private val root = entry.root.hexToByteArray()
private val communityIdBytes = entry.id.hexToByteArray()
private val controlPlaneKey: GroupKey = ConcordActions.controlPlane(root, communityIdBytes, entry.rootEpoch)
/**
* The Control Plane as this account holds it (CORD-02 §5): split when the entry
* carries a `control_pk` (plus the write key when this account is staff and
* holds the `control_root`), legacy single-key otherwise.
*/
@Volatile
private var controlKeys: ControlPlaneKeys = ConcordActions.controlPlaneKeysFor(entry)
/** The Guestbook Plane at this epoch — where member join/leave motions ride (CORD-02 §5). */
private val guestbookKey: GroupKey = ConcordActions.guestbookPlane(root, communityIdBytes, entry.rootEpoch)
@@ -116,7 +137,7 @@ class ConcordCommunitySession(
private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch)
/** The Control Plane stream address to subscribe to (known from the entry alone). */
val controlPlaneAddress: HexKey get() = controlPlaneKey.publicKeyHex
val controlPlaneAddress: HexKey get() = controlKeys.address
/** The Guestbook Plane stream address to subscribe to (known from the entry alone). */
val guestbookAddress: HexKey get() = guestbookKey.publicKeyHex
@@ -136,14 +157,16 @@ class ConcordCommunitySession(
* `heldRoots` is already persisted in the kind-13302 community list, that memory
* survives a process restart without any new storage.
*/
private val historicalControlKeys: Map<HexKey, Pair<GroupKey, Long>> =
private val historicalControlKeys: Map<HexKey, Pair<ControlPlaneKeys, Long>> =
entry.heldRoots
.filter { it.epoch < entry.rootEpoch }
.sortedByDescending { it.epoch }
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
.mapNotNull { held ->
val key = runCatching { ConcordActions.controlPlane(held.key.hexToByteArray(), communityIdBytes, held.epoch) }.getOrNull() ?: return@mapNotNull null
key.publicKeyHex to (key to held.epoch)
// A held split epoch's address is the banked control_pk (held, never derivable);
// a held legacy epoch derives its address from the root as it always did.
val keys = runCatching { ConcordActions.controlPlaneKeys(held.key.hexToByteArray(), communityIdBytes, held.epoch, held.controlPk, held.controlRoot) }.getOrNull() ?: return@mapNotNull null
keys.address to (keys to held.epoch)
}.toMap()
/** The prior-epoch Control Plane addresses to subscribe to, so the rollback floor can be rebuilt. */
@@ -289,13 +312,19 @@ class ConcordCommunitySession(
* NOT included here: mixing them into the shared control/channel AUTH set starved the
* subscription on relays that gate a REQ on stream-key AUTH (control stopped folding,
* channels went empty). They AUTH on their own isolated subscription instead.
*
* A **split** Control Plane epoch contributes a key only when this account is staff
* (CORD-02 §2): a regular member holds the `control_pk` but not the secret behind it,
* so it cannot answer an AUTH challenge as the plane which is the write-restriction
* working as designed, not a gap to paper over. A legacy epoch still contributes, its
* member-held derivation being address and signer at once (CORD-02 §5).
*/
fun streamKeys(): List<GroupKey> =
lock.withLock {
listOf(controlPlaneKey) +
listOfNotNull(controlKeys.signer) +
// Prior-epoch Control Planes: the anti-rollback floor is folded from them, so the
// gated relays must serve their wraps too.
historicalControlKeys.values.map { it.first } +
historicalControlKeys.values.mapNotNull { it.first.signer } +
channelKeysByAddress.values.map { it.second } +
// Prior-epoch channel stream keys so the gated relays serve their older wraps too.
historicalChannelKeysByAddress.values.map { it.second }
@@ -305,13 +334,48 @@ class ConcordCommunitySession(
fun auxStreamKeys(): List<GroupKey> = listOf(guestbookKey, nextBaseRekeyKey)
/** The community's current Control Plane editions — the input a moderation edition chains onto. */
fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlPlaneKey) }
fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) }
/** The raw Control Plane wraps buffered so far — the input a Refounding compacts (CORD-06 §3). */
fun controlPlaneWraps(): List<Event> = lock.withLock { controlWraps.values.toList() }
/** The Control Plane key, for authoring moderation editions. */
fun controlPlaneKey(): GroupKey = controlPlaneKey
/**
* The Control Plane keys as this account holds them, for authoring moderation
* editions. [ControlPlaneKeys.canWrite] is false for a regular member on a split
* epoch (CORD-02 §2) the caller must not attempt to publish an edition then.
*/
fun controlPlaneKeys(): ControlPlaneKeys = lock.withLock { controlKeys }
/**
* Adopt Control Plane key material that arrived *after* this session was built, at
* the same epoch: the `control_root` a staff-making Grant delivers (CORD-04 §3), or
* a `control_pk` filled in by a same-epoch Community List merge (CORD-02 §8).
*
* Done in place rather than by rebuilding the session, because a rebuild would drop
* the buffered Control Plane wraps and leave the community folded empty until every
* wrap happened to be re-delivered. Nothing about the *plane* moves here: adoption is
* gated on the secret deriving to exactly the `control_pk` already held (CORD-02 §5),
* so the address, the read key, the buffered wraps and the subscription set are all
* invariant only [ControlPlaneKeys.signer] appears, flipping
* [ControlPlaneKeys.canWrite] and adding the stream key to [streamKeys].
*
* Fails closed and returns false when [newEntry] is not the same community at the
* same root and epoch, or when the material it carries would move the plane's
* address a caller must rebuild the session for that, never mutate it. Returns
* false too when nothing changed, so the caller can skip a needless revision bump.
*/
fun adoptControlMaterial(newEntry: ConcordCommunityListEntry): Boolean =
lock.withLock {
if (newEntry.id != entry.id || newEntry.root != entry.root || newEntry.rootEpoch != entry.rootEpoch) return@withLock false
if (newEntry.controlPk == entry.controlPk && newEntry.controlRoot == entry.controlRoot) return@withLock false
val newKeys = ConcordActions.controlPlaneKeysFor(newEntry)
// The plane is where the buffered wraps already are. If the new material points
// somewhere else, this is not an adoption — refuse and let the caller rebuild.
if (newKeys.address != controlKeys.address) return@withLock false
entry = newEntry
controlKeys = newKeys
true
}
/** This account's standing, from the current fold. */
fun membership(): ConcordMembership {
@@ -447,7 +511,7 @@ class ConcordCommunitySession(
val wraps = controlWraps.values.toList()
val folded =
ConcordCommunityState.fold(
editionsLocked(wraps, controlPlaneKey),
editionsLocked(wraps, controlKeys),
entry.owner,
controlFloorsLocked(),
)
@@ -486,13 +550,13 @@ class ConcordCommunitySession(
*/
private fun editionsLocked(
wraps: Collection<Event>,
planeKey: GroupKey,
planeKeys: ControlPlaneKeys,
): List<ControlEdition> =
wraps.mapNotNull { wrap ->
if (editionByWrapId.containsKey(wrap.id)) {
editionByWrapId[wrap.id]
} else {
val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKey)?.let { ControlEdition.fromRumor(it.rumor) }
val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKeys)?.let { ControlEdition.fromRumor(it.rumor) }
editionByWrapId[wrap.id] = edition
edition
}
@@ -20,12 +20,12 @@
*/
package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent
@@ -39,13 +39,31 @@ enum class ConcordPlaneKind {
CHANNEL,
}
/** A known Concord plane: its kind, community, optional channel, and the key to open its wraps. */
/**
* A known Concord plane: its kind, community, optional channel, and what it takes
* to open its wraps the stream [address] they must be authored by, and the
* [readConversationKey] their content decrypts under.
*
* The two are separate fields rather than one [GroupKey] because a split Control
* Plane separates them (CORD-01, Write-Restricted Streams): the address is the
* staff-held signer's pubkey, while the read key derives from the `community_root`
* every member holds. On a channel plane and a legacy Control Plane they are the
* two halves of the same key.
*/
class ConcordPlane(
val kind: ConcordPlaneKind,
val communityId: HexKey,
val channelId: ConcordChannelId?,
val key: GroupKey,
)
val address: HexKey,
val readConversationKey: ByteArray,
) {
constructor(
kind: ConcordPlaneKind,
communityId: HexKey,
channelId: ConcordChannelId?,
key: GroupKey,
) : this(kind, communityId, channelId, key.publicKeyHex, key.conversationKey)
}
/** The routed result of opening an inbound wrap that belonged to a known plane. */
class RoutedRumor(
@@ -72,12 +90,18 @@ class ConcordPlaneRegistry {
private val lock = KmpLock()
private val planes = HashMap<HexKey, ConcordPlane>()
/** Registers every joined community's Control Plane address. Idempotent. */
/**
* Registers every joined community's Control Plane address. Idempotent.
*
* On a split epoch the address is the entry's held `control_pk` and the wraps
* still decrypt under the `community_root`-derived read key (CORD-02 §5); on a
* legacy entry (no `control_pk`) both come from the old single derivation.
*/
fun registerControlPlanes(entries: List<ConcordCommunityListEntry>) =
lock.withLock {
for (e in entries) {
val cp = ConcordKeyDerivation.controlPlaneKey(e.root.hexToByteArray(), e.id.hexToByteArray(), e.rootEpoch)
planes[cp.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CONTROL, e.id, null, cp)
val cp = ConcordActions.controlPlaneKeysFor(e)
planes[cp.address] = ConcordPlane(ConcordPlaneKind.CONTROL, e.id, null, cp.address, cp.readKey.conversationKey)
}
}
@@ -106,7 +130,7 @@ class ConcordPlaneRegistry {
*/
fun route(wrap: Event): RoutedRumor? {
val plane = planeFor(wrap.pubKey) ?: return null
val opened = ConcordStreamEnvelope.openOrNull(wrap, plane.key) ?: return null
val opened = ConcordStreamEnvelope.openOrNull(wrap, plane.address, plane.readConversationKey) ?: return null
return RoutedRumor(plane, opened)
}
@@ -78,6 +78,13 @@ class ConcordSessionRegistry(
if (existing == null || existing.entry.root != entry.root || existing.entry.rootEpoch != entry.rootEpoch) {
sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor)
created += id
} else {
// Same epoch, but the Control Plane write key may have just arrived — a
// staff-making Grant delivers it inside the fold itself (CORD-04 §3), long
// after this session was built. Adopt it in place: rebuilding would drop the
// buffered wraps and fold the community empty, and the plane's address is
// invariant under adoption anyway (CORD-02 §5).
existing.adoptControlMaterial(entry)
}
}
created
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.actions
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
@@ -63,6 +64,9 @@ class ConcordActionsTest {
rootEpoch = community.rootEpoch,
name = "Nostrichs",
relays = listOf("wss://r.example"),
// Without this the joiner has no Control Plane address to fold at — the
// bundle is the only place it can come from (CORD-05 §1).
controlPk = community.controlPkHex,
)
val minted = ConcordActions.mintInviteLink("https://vector.chat", invite, createdAt = 1L)
@@ -103,29 +107,47 @@ class ConcordActionsTest {
val carol = NostrSignerInternal(KeyPair()) // removed
val newRoot = ByteArray(32) { 0x33 }
// A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2).
val newControlRoot = ByteArray(32) { 0x44 }
val build =
ConcordActions.buildRefounding(
rotatorSigner = owner,
communityId = community.communityIdHex,
priorRoot = community.communityRoot,
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = community.rootEpoch,
priorControlWraps = community.genesisWraps,
priorControlKey = community.controlPlane,
priorControlKeys = community.controlPlane,
recipientsXOnly = listOf(owner.pubKey, alice.pubKey),
// Only the owner is staff, so only the owner's blob carries the secret.
staffXOnly = setOf(owner.pubKey),
createdAt = 5L,
)
val baseRekey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch)
val aliceGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, alice, community.communityRoot, community.rootEpoch)
val carolGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, carol, community.communityRoot, community.rootEpoch)
val aliceGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, alice, community.communityIdHex, community.communityRoot, community.rootEpoch)
val carolGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, carol, community.communityIdHex, community.communityRoot, community.rootEpoch)
assertNotNull(aliceGot)
assertEquals(community.rootEpoch + 1, aliceGot.newEpoch)
assertTrue(carolGot == null)
// The compacted Control Plane folds identically under the new root.
val newControl = ConcordActions.controlPlane(aliceGot.newRoot, community.communityId, aliceGot.newEpoch)
// Alice is a plain member: her blob carries the new control_pk to read with, never the
// secret to write with (CORD-06 §1).
val deliveredControlPk = aliceGot.newControlPk
assertNotNull(deliveredControlPk)
assertTrue(aliceGot.newControlRoot == null, "a member's base blob must not carry the write key")
// The compacted Control Plane folds identically at the new epoch, opened the way a
// member does: the delivered address plus the derived read key.
val newControl =
ConcordActions.controlPlaneKeys(
communityRoot = aliceGot.newRoot,
communityId = community.communityId,
rootEpoch = aliceGot.newEpoch,
controlPk = deliveredControlPk.toHexKey(),
)
val state = ConcordActions.foldCommunity(build.controlWraps, newControl, community.ownerPubKey)
assertEquals("Test", state.metadata?.name)
assertTrue(state.channels.isNotEmpty())
@@ -22,10 +22,13 @@ package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -34,6 +37,8 @@ import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConcordModerationTest {
@@ -214,4 +219,81 @@ class ConcordModerationTest {
assertTrue(state.authority.isBanned(troll.pubKey), "the forged unban must not free the troll")
assertTrue(state.authority.isBanned(stranger.pubKey))
}
/**
* The staff-delivery decision (CORD-04 §3): a Grant that hands out a Control-writing
* bit must carry the `control_root` in the same edition (`control_wrap`), and every
* other shape of Grant must not a non-staff role, a revoke, an unresolvable role,
* or a granter who holds no secret to deliver.
*/
@Test
fun aStaffMakingGrantDeliversTheControlRootAndNothingElseDoes() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val cp = community.controlPlane
val communityId = community.communityId
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
// A staff role (BAN writes Control editions) and a non-staff one (KICK writes the Guestbook).
val staffRoleId = ByteArray(32) { 0x51 }
val staffRoleIdHex = staffRoleId.toHexKey()
val kickRoleId = ByteArray(32) { 0x52 }
val kickRoleIdHex = kickRoleId.toHexKey()
editions +=
ConcordActions.controlEditions(
listOf(
ConcordModeration.defineRole(owner, cp, staffRoleId, RoleEntity(name = "Mod", position = 2, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire()), editions, createdAt = 2L, owner = community.ownerPubKey),
),
cp,
)
editions +=
ConcordActions.controlEditions(
listOf(
ConcordModeration.defineRole(owner, cp, kickRoleId, RoleEntity(name = "Bouncer", position = 3, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire()), editions, createdAt = 3L, owner = community.ownerPubKey),
),
cp,
)
assertTrue(ConcordModeration.makesStaff(listOf(staffRoleIdHex), editions, community.ownerPubKey))
assertFalse(ConcordModeration.makesStaff(listOf(kickRoleIdHex), editions, community.ownerPubKey))
assertFalse(ConcordModeration.makesStaff(emptyList(), editions, community.ownerPubKey), "a revoke hands out nothing")
assertFalse(ConcordModeration.makesStaff(listOf("ee".repeat(32)), editions, community.ownerPubKey), "an unresolvable role must not trigger a delivery")
suspend fun grantEntity(
roleIds: List<String>,
controlRoot: ByteArray?,
): GrantEntity {
val wrap =
ConcordModeration.grantWithStaffDelivery(
actor = owner,
controlPlane = cp,
communityId = communityId,
member = admin.pubKey,
roleIds = roleIds,
current = editions,
createdAt = 4L,
owner = community.ownerPubKey,
controlRoot = controlRoot,
epoch = community.rootEpoch,
)
val edition = ConcordActions.controlEditions(listOf(wrap), cp).single()
return ConcordJson.decodeOrNull<GrantEntity>(edition.content)!!
}
// A staff-making Grant carries the wrap; the promotee opens it and it derives to the
// control_pk every member holds for the epoch — the adoption gate (CORD-04 §3).
val staffGrant = grantEntity(listOf(staffRoleIdHex), community.controlRoot)
val controlWrap = staffGrant.controlWrap
assertNotNull(controlWrap, "a staff-making Grant must deliver the write key in the same edition")
val opened = ControlRootWrap.openOrNull(controlWrap, admin, owner.pubKey)
assertNotNull(opened, "the promotee must be able to open the delivery")
assertEquals(community.rootEpoch, opened.epoch, "the wrap must be fresh for the current epoch")
assertTrue(ControlRootWrap.derivesTo(opened.controlRoot, communityId, community.rootEpoch, community.controlPkHex))
// Every other shape is a plain Grant.
assertNull(grantEntity(listOf(kickRoleIdHex), community.controlRoot).controlWrap, "a Guestbook-writing role needs no key")
assertNull(grantEntity(emptyList(), community.controlRoot).controlWrap, "a revoke delivers nothing")
assertNull(grantEntity(listOf("ee".repeat(32)), community.controlRoot).controlWrap, "an unresolved role conservatively delivers nothing")
assertNull(grantEntity(listOf(staffRoleIdHex), controlRoot = null).controlWrap, "no held secret, no delivery (legacy community or keyless granter)")
}
}
@@ -48,6 +48,8 @@ class ConcordSubscriptionPlannerTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
heldRoots =
listOf(
com.vitorpamplona.quartz.concord.cord02Community
@@ -78,6 +80,8 @@ class ConcordSubscriptionPlannerTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
@@ -85,7 +89,7 @@ class ConcordSubscriptionPlannerTest {
// Control-plane sub address must equal the derived control plane pk.
val controlSubs = ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry))
assertEquals(1, controlSubs.size)
assertEquals(community.controlPlane.publicKeyHex, controlSubs[0].pubKeyHex)
assertEquals(community.controlPlane.address, controlSubs[0].pubKeyHex)
assertTrue(controlSubs[0].channelId == null)
// Channel-plane subs cover the folded #general channel.
@@ -103,7 +107,7 @@ class ConcordSubscriptionPlannerTest {
assertEquals(1, filters.size) // single relay
val filter = filters.values.first().first()
assertEquals(listOf(1059), filter.kinds)
assertTrue(filter.authors!!.contains(community.controlPlane.publicKeyHex))
assertTrue(filter.authors!!.contains(community.controlPlane.address))
assertTrue(filter.authors!!.contains(general.pubKeyHex))
}
@@ -118,6 +122,8 @@ class ConcordSubscriptionPlannerTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
@@ -147,6 +153,8 @@ class ConcordSubscriptionPlannerTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
@@ -176,6 +184,8 @@ class ConcordSubscriptionPlannerTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
@@ -189,7 +199,7 @@ class ConcordSubscriptionPlannerTest {
assertEquals(relay, filters[0].relay)
assertEquals(listOf(1059, 21059), filters[0].filter.kinds)
assertEquals(1234L, filters[0].filter.since)
assertTrue(filters[0].filter.authors!!.contains(community.controlPlane.publicKeyHex))
assertTrue(filters[0].filter.authors!!.contains(community.controlPlane.address))
// No planes resolve to a relay -> nothing to subscribe.
assertNull(ConcordSubscriptionPlanner.relayBasedFilters(emptyList(), null))
@@ -210,12 +220,14 @@ class ConcordSubscriptionPlannerTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey)
val controlPk = community.controlPlane.publicKeyHex
val controlPk = community.controlPlane.address
val guestbookPk = ConcordActions.guestbookPlane(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex
val generalPk = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch).publicKeyHex
@@ -53,6 +53,8 @@ class ConcordCommunitySessionTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
heldRoots = listOf(HeldRoot(priorEpoch, priorRoot.toHexKey())),
relays = listOf("wss://r.example"),
name = "Nostrichs",
@@ -98,13 +100,15 @@ class ConcordCommunitySessionTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val captured = mutableListOf<Triple<String, String, com.vitorpamplona.quartz.nip01Core.core.Event>>()
val session = ConcordCommunitySession(entry, owner.pubKey) { communityId, channelIdHex, rumor, _ -> captured += Triple(communityId, channelIdHex, rumor) }
assertEquals(community.controlPlane.publicKeyHex, session.controlPlaneAddress)
assertEquals(community.controlPlane.address, session.controlPlaneAddress)
// Feed the genesis control wraps → state folds, channels + membership resolve. A fold is
// STRUCTURAL (it moves the subscription set), so it's allowed to bump the revision.
@@ -48,6 +48,8 @@ class ConcordPlaneRegistryTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -73,7 +74,10 @@ class ConcordRollbackFloorTest {
// silently dropped. Every wrap it publishes is a genuine, owner-signed edition.
val newRoot = ByteArray(32) { 0x33 }
val newEpoch = community.rootEpoch + 1
val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch)
// The rotator mints a fresh control_root beside the new root (CORD-02 §2), so the
// new epoch's plane is split and addressed by the derived signer, not the root.
val newControlRoot = ByteArray(32) { 0x44 }
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl)
val entry =
@@ -83,7 +87,11 @@ class ConcordRollbackFloorTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = newRoot.toHexKey(),
rootEpoch = newEpoch,
heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey())),
controlPk = newControl.address,
controlRoot = newControlRoot.toHexKey(),
// The prior epoch is banked with the address it was folded at: a split epoch's
// Control Plane can never be re-derived, only remembered (CORD-02 §2).
heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey(), community.controlPkHex, community.controlRoot.toHexKey())),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
@@ -92,11 +100,11 @@ class ConcordRollbackFloorTest {
// The prior epoch's Control Plane is subscribed and AUTHed for — that is where the floor
// comes from, and without it the client has no memory to check the rotator against.
assertTrue(
session.historicalControlPlaneAddresses().contains(community.controlPlane.publicKeyHex),
session.historicalControlPlaneAddresses().contains(community.controlPlane.address),
"prior-epoch control plane not subscribed",
)
assertTrue(
session.streamKeys().any { it.publicKeyHex == community.controlPlane.publicKeyHex },
session.streamKeys().any { it.publicKeyHex == community.controlPlane.address },
"prior-epoch control plane not AUTHed",
)
@@ -132,7 +140,10 @@ class ConcordRollbackFloorTest {
val newRoot = ByteArray(32) { 0x33 }
val newEpoch = community.rootEpoch + 1
val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch)
// The rotator mints a fresh control_root beside the new root (CORD-02 §2), so the
// new epoch's plane is split and addressed by the derived signer, not the root.
val newControlRoot = ByteArray(32) { 0x44 }
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
// Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives.
val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl)
@@ -143,7 +154,11 @@ class ConcordRollbackFloorTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = newRoot.toHexKey(),
rootEpoch = newEpoch,
heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey())),
controlPk = newControl.address,
controlRoot = newControlRoot.toHexKey(),
// The prior epoch is banked with the address it was folded at: a split epoch's
// Control Plane can never be re-derived, only remembered (CORD-02 §2).
heldRoots = listOf(HeldRoot(community.rootEpoch, community.communityRoot.toHexKey(), community.controlPkHex, community.controlRoot.toHexKey())),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
@@ -167,7 +182,10 @@ class ConcordRollbackFloorTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val newRoot = ByteArray(32) { 0x33 }
val newEpoch = community.rootEpoch + 1
val newControl = ConcordActions.controlPlane(newRoot, community.communityId, newEpoch)
// The rotator mints a fresh control_root beside the new root (CORD-02 §2), so the
// new epoch's plane is split and addressed by the derived signer, not the root.
val newControlRoot = ByteArray(32) { 0x44 }
val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot)
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl)
val entry =
@@ -177,6 +195,7 @@ class ConcordRollbackFloorTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = newRoot.toHexKey(),
rootEpoch = newEpoch,
controlPk = newControl.address,
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
@@ -47,6 +47,8 @@ class ConcordSessionManagerTest {
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = name,
)
@@ -61,7 +63,7 @@ class ConcordSessionManagerTest {
testScheduler.runCurrent()
// The joined community produced a session, and its control plane is in the subscribe set.
assertTrue(manager.subscribeAddresses().contains(alpha.controlPlane.publicKeyHex))
assertTrue(manager.subscribeAddresses().contains(alpha.controlPlane.address))
val revAfterSync = manager.revision.value
assertTrue(revAfterSync > 0)
@@ -87,7 +89,7 @@ class ConcordSessionManagerTest {
val beta = ConcordCommunityFactory.create(owner, "Beta", createdAt = 1L, relays = listOf("wss://r.example"))
communities.value = listOf(entryFor(alpha, "Alpha"), entryFor(beta, "Beta"))
testScheduler.runCurrent()
assertTrue(manager.subscribeAddresses().contains(beta.controlPlane.publicKeyHex))
assertTrue(manager.subscribeAddresses().contains(beta.controlPlane.address))
// Alpha's fold survived the re-sync.
assertEquals(
"Alpha",
@@ -114,7 +116,13 @@ class ConcordSessionManagerTest {
// Before any fold, only the control-plane key must AUTH — and only on the community's relay.
val beforeFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() }
assertTrue(beforeFold.contains(alpha.controlPlane.secretKey.toHexKey()))
assertTrue(
beforeFold.contains(
alpha.controlPlane.signer!!
.secretKey
.toHexKey(),
),
)
assertTrue(manager.streamAuthSecretsFor(elsewhere).isEmpty()) // relay-scoped
// After the Control Plane folds, the #general channel key joins the AUTH set.
@@ -122,7 +130,13 @@ class ConcordSessionManagerTest {
testScheduler.runCurrent()
val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch)
val afterFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() }
assertTrue(afterFold.contains(alpha.controlPlane.secretKey.toHexKey()))
assertTrue(
afterFold.contains(
alpha.controlPlane.signer!!
.secretKey
.toHexKey(),
),
)
assertTrue(afterFold.contains(general.secretKey.toHexKey()))
assertFalse(manager.streamAuthSecretsFor(elsewhere).any { it.toHexKey() == general.secretKey.toHexKey() })
}
@@ -30,8 +30,10 @@ 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.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
class ConcordSessionRegistryTest {
@@ -40,12 +42,16 @@ class ConcordSessionRegistryTest {
private fun entryFor(
community: NewConcordCommunity,
name: String,
controlRoot: String? = community.controlRoot.toHexKey(),
rootEpoch: Long = community.rootEpoch,
) = ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
rootEpoch = rootEpoch,
controlPk = community.controlPkHex,
controlRoot = controlRoot,
relays = listOf("wss://r.example"),
name = name,
)
@@ -66,8 +72,8 @@ class ConcordSessionRegistryTest {
assertNotNull(registry.sessionFor(beta.communityIdHex))
// Both control-plane addresses are in the subscribe set from the entries alone.
assertTrue(registry.subscribeAddresses().contains(alpha.controlPlane.publicKeyHex))
assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.publicKeyHex))
assertTrue(registry.subscribeAddresses().contains(alpha.controlPlane.address))
assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.address))
// A genesis control wrap routes to Alpha's session and folds it (STRUCTURAL).
alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL_FOLD, registry.ingest(it)) }
@@ -102,4 +108,59 @@ class ConcordSessionRegistryTest {
val gamma = ConcordCommunityFactory.create(owner, "Gamma", createdAt = 1L, relays = listOf("wss://r.example"))
assertEquals(ConcordIngestOutcome.NOT_MINE, registry.ingest(gamma.genesisWraps.first()))
}
/**
* A promotion to staff delivers the `control_root` inside the fold itself (CORD-04 §3),
* so it lands on an entry whose session was built as a read-only member long before. The
* session must pick the key up **without** being rebuilt: a rebuild would drop the
* buffered Control Plane wraps and fold the community empty, which is exactly what the
* user would see instead of their new moderation powers.
*/
@Test
fun adoptsAControlRootDeliveredMidSessionWithoutLosingTheFold() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example"))
val registry = ConcordSessionRegistry()
// Joined as a plain member: the address is held, the write secret is not.
val asMember = entryFor(community, "Alpha", controlRoot = null)
registry.sync(listOf(asMember), owner.pubKey)
val session = registry.sessionFor(community.communityIdHex)!!
community.genesisWraps.forEach { registry.ingest(it) }
assertEquals(
"Alpha",
session.state.value
?.metadata
?.name,
)
assertFalse(session.controlPlaneKeys().canWrite, "a member holds no control_root")
// The staff-making Grant lands and the drain writes the secret onto the entry.
val asStaff = entryFor(community, "Alpha")
val createdOnAdopt = registry.sync(listOf(asStaff), owner.pubKey)
// Adopted in place: same session object, no rebuild.
assertTrue(createdOnAdopt.isEmpty(), "adopting a control_root must not rebuild the session")
assertSame(session, registry.sessionFor(community.communityIdHex))
// The write key is live...
assertTrue(session.controlPlaneKeys().canWrite, "the delivered control_root must flip canWrite")
assertEquals(community.controlRoot.toHexKey(), session.entry.controlRoot, "the entry carries it onward for the next Grant")
assertTrue(session.streamKeys().any { it.publicKeyHex == community.controlPkHex }, "staff now AUTHs as the plane")
// ...and the address and the fold are untouched — the bug this guards.
assertEquals(community.controlPlane.address, session.controlPlaneAddress)
assertEquals(
"Alpha",
session.state.value
?.metadata
?.name,
"adoption must not discard the buffered wraps",
)
// A real rotation still rebuilds rather than adopting in place.
val nextEpoch = entryFor(community, "Alpha", rootEpoch = community.rootEpoch + 1)
assertEquals(setOf(community.communityIdHex), registry.sync(listOf(nextEpoch), owner.pubKey))
}
}
@@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -45,9 +45,12 @@ class NewConcordCommunity(
val ownerPubKey: String,
val ownerSalt: ByteArray,
val communityRoot: ByteArray,
/** The staff write key (CORD-02 §2): held by the owner and staff only, never members. */
val controlRoot: ByteArray,
val rootEpoch: Long,
val generalChannelId: ByteArray,
val controlPlane: GroupKey,
/** The split Control Plane keys (owner view: signer held, [ControlPlaneKeys.canWrite] true). */
val controlPlane: ControlPlaneKeys,
/** The kind-1059 control-plane wraps to publish (metadata + #general). */
val genesisWraps: List<Event>,
/** The same editions as parsed [ControlEdition]s, for immediate local folding. */
@@ -55,6 +58,9 @@ class NewConcordCommunity(
) {
val communityIdHex: String get() = communityId.toHexKey()
val generalChannelIdHex: String get() = generalChannelId.toHexKey()
/** The Control Plane address (`control_pk`) members hold to subscribe/verify/read. */
val controlPkHex: String get() = controlPlane.address
}
/**
@@ -63,9 +69,11 @@ class NewConcordCommunity(
* `create` mints a random `owner_salt`, derives the self-certifying
* `community_id = sha256("concord/community" owner salt)`, generates an
* independent random `community_root` (so access can rotate while identity stays
* fixed), and emits exactly two owner-signed genesis editions the community
* metadata and a public `#general` channel as plaintext-seal wraps on the
* Control Plane at epoch 0.
* fixed) plus the staff-held `control_root` write key (CORD-02 §2), and emits
* exactly two owner-signed genesis editions the community metadata and a public
* `#general` channel as plaintext-seal wraps on the split Control Plane at
* epoch 0: signed by the `control_root`-derived signer, readable under the
* `community_root`-derived read key (CORD-02 §5).
*/
object ConcordCommunityFactory {
const val GENERAL_CHANNEL_NAME = "general"
@@ -82,9 +90,13 @@ object ConcordCommunityFactory {
val ownerSalt = ConcordKeyDerivation.newOwnerSalt()
val communityId = ConcordKeyDerivation.communityId(ownerXOnly, ownerSalt)
val communityRoot = RandomInstance.bytes(32)
// The staff write key, minted alongside the community_root and kept deliberately
// apart from it (CORD-02 §2): members derive the Control read key from the root,
// but only control_root holders can mint a wrap at the plane's address.
val controlRoot = RandomInstance.bytes(32)
val generalChannelId = RandomInstance.bytes(32)
val rootEpoch = 0L
val controlPlane = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch)
val controlPlane = ControlPlaneKeys.forStaff(communityRoot, communityId, rootEpoch, controlRoot)
val metadataJson =
ConcordJson.instance.encodeToString(
@@ -127,6 +139,7 @@ object ConcordCommunityFactory {
ownerPubKey = ownerSigner.pubKey,
ownerSalt = ownerSalt,
communityRoot = communityRoot,
controlRoot = controlRoot,
rootEpoch = rootEpoch,
generalChannelId = generalChannelId,
controlPlane = controlPlane,
@@ -44,6 +44,13 @@ val NoExtras: JsonObject = JsonObject(emptyMap())
/**
* A past root key for a specific epoch, kept so historical channel keys stay derivable.
*
* [controlPk] is that epoch's Control Plane address (CORD-02 §5) when the epoch was
* split a `control_pk` is held, never derivable from the root, so without keeping
* it a prior split epoch's Control Plane could not be re-subscribed for the
* anti-rollback floor. Null for a legacy (pre-split) epoch, whose address the root
* still derives. A client-extension field on the wire (`control_pk` inside the held
* root object), preserved verbatim by extras-honoring peers.
*
* [extras] carries any key another client wrote inside this held root that we do not
* model, so a read-modify-write does not delete it (see [ConcordCommunityList]).
*/
@@ -51,6 +58,15 @@ val NoExtras: JsonObject = JsonObject(emptyMap())
class HeldRoot(
val epoch: Long,
val key: String,
val controlPk: String? = null,
/**
* That epoch's staff write key, banked only if we held it. It buys nothing on the
* wire the epoch is frozen, so writing to it is pointless but a relay that gates
* a plane's REQ on NIP-42 AUTH as the stream key will not serve the old Control Plane
* without it, and those wraps are what rebuild the anti-rollback floor. A member who
* was never staff simply has none, and reads the epoch wherever the relay allows.
*/
val controlRoot: String? = null,
val extras: JsonObject = NoExtras,
)
@@ -148,6 +164,21 @@ class ConcordCommunityListEntry(
val ownerSalt: String,
val root: String,
val rootEpoch: Long = 0,
/**
* The Control Plane signer's pubkey at [rootEpoch] (CORD-02 §2/§8): read
* access, never write. Null = a legacy, pre-split epoch fold Control at the
* legacy address (CORD-06 §3).
*/
val controlPk: String? = null,
/**
* The staff write key at [rootEpoch] (CORD-02 §2), when this account is staff
* and has adopted it (community creation, a Grant's `control_wrap`, or a
* 136-byte base rekey blob). Null for a plain member or a legacy epoch. Part
* of the join material since CORD-02 §8 ("plus `control_root` when the member
* holds it") — the List carries every private key its holder has, so a
* staffer's own devices can write.
*/
val controlRoot: String? = null,
val heldRoots: List<HeldRoot> = emptyList(),
val privateChannels: List<PrivateChannelKey> = emptyList(),
val relays: List<String> = emptyList(),
@@ -250,6 +281,8 @@ object ConcordCommunityList {
private class WireHeldRoot(
val epoch: Long,
val key: String,
@SerialName("control_pk") val controlPk: String? = null,
@SerialName("control_root") val controlRoot: String? = null,
@SerialName(EXTRAS) val extras: JsonObject = NoExtras,
)
@@ -260,6 +293,8 @@ object ConcordCommunityList {
@SerialName("owner_salt") val ownerSalt: String,
@SerialName("community_root") val communityRoot: String,
@SerialName("root_epoch") val rootEpoch: Long,
@SerialName("control_pk") val controlPk: String? = null,
@SerialName("control_root") val controlRoot: String? = null,
val channels: List<
@Serializable(WireChannelSerializer::class)
WireChannel,
@@ -315,10 +350,12 @@ object ConcordCommunityList {
ownerSalt = ownerSalt,
communityRoot = root,
rootEpoch = rootEpoch,
controlPk = controlPk,
controlRoot = controlRoot,
channels = privateChannels.map { WireChannel(it.channelId, it.key, it.epoch, it.name, it.extras) },
relays = relays,
name = name,
heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key, it.extras) },
heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key, it.controlPk, it.controlRoot, it.extras) },
extras = residue.currentExtras,
)
@@ -333,7 +370,9 @@ object ConcordCommunityList {
ownerSalt = ownerSalt,
root = communityRoot,
rootEpoch = rootEpoch,
heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key, it.extras) },
controlPk = controlPk,
controlRoot = controlRoot,
heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key, it.controlPk, it.controlRoot, it.extras) },
privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name, it.extras) },
relays = relays,
name = name,
@@ -499,19 +538,53 @@ object ConcordCommunityList {
val byId = LinkedHashMap<String, ConcordCommunityListEntry>()
for (e in a + b) {
val existing = byId[e.id]
if (existing == null) {
byId[e.id] = e
} else if (e.rootEpoch > existing.rootEpoch) {
// A winner without an invite_ref inherits the loser's: that link is the only anchor
// stranded recovery has, and dropping it on a merge would disarm recovery forever.
byId[e.id] = if (e.inviteRef == null) e.withInviteRef(existing.inviteRef) else e
} else if (existing.inviteRef == null && e.inviteRef != null) {
byId[e.id] = existing.withInviteRef(e.inviteRef)
}
byId[e.id] =
when {
existing == null -> e
// A winner without an invite_ref inherits the loser's: that link is the only anchor
// stranded recovery has, and dropping it on a merge would disarm recovery forever.
// Control key material is NOT inherited across epochs — a lower epoch's
// control_pk/control_root is stale for the winner's planes (CORD-06).
e.rootEpoch > existing.rootEpoch -> e.copyWith(inviteRef = e.inviteRef ?: existing.inviteRef)
e.rootEpoch < existing.rootEpoch -> existing.copyWith(inviteRef = existing.inviteRef ?: e.inviteRef)
else ->
// Same epoch: both sides describe the same planes, so fill whatever key
// material either is missing — e.g. one device was promoted to staff
// (control_root via a Grant's control_wrap) or joined through a newer
// bundle (control_pk) while the other holds the invite anchor.
existing.copyWith(
controlPk = existing.controlPk ?: e.controlPk,
controlRoot = existing.controlRoot ?: e.controlRoot,
inviteRef = existing.inviteRef ?: e.inviteRef,
)
}
}
return byId.values.toList()
}
/** Field-selective copy (the entry is not a data class). Defaults keep every field. */
private fun ConcordCommunityListEntry.copyWith(
controlPk: String? = this.controlPk,
controlRoot: String? = this.controlRoot,
inviteRef: String? = this.inviteRef,
) = ConcordCommunityListEntry(
id = id,
owner = owner,
ownerSalt = ownerSalt,
root = root,
rootEpoch = rootEpoch,
controlPk = controlPk,
controlRoot = controlRoot,
heldRoots = heldRoots,
privateChannels = privateChannels,
relays = relays,
name = name,
addedAt = addedAt,
inviteRef = inviteRef,
excludedAtEpoch = excludedAtEpoch,
residue = residue,
)
/** Copy of this entry carrying [inviteRef]; every other field untouched. */
fun ConcordCommunityListEntry.withInviteRef(inviteRef: String?) =
ConcordCommunityListEntry(
@@ -520,6 +593,32 @@ object ConcordCommunityList {
ownerSalt = ownerSalt,
root = root,
rootEpoch = rootEpoch,
controlPk = controlPk,
controlRoot = controlRoot,
heldRoots = heldRoots,
privateChannels = privateChannels,
relays = relays,
name = name,
addedAt = addedAt,
inviteRef = inviteRef,
excludedAtEpoch = excludedAtEpoch,
residue = residue,
)
/**
* Copy of this entry holding [controlRoot] the staff write key, adopted after a
* promotion delivered it (CORD-04 §3) or a base rotation carried it (CORD-06 §1).
* Every other field untouched.
*/
fun ConcordCommunityListEntry.withControlRoot(controlRoot: String?) =
ConcordCommunityListEntry(
id = id,
owner = owner,
ownerSalt = ownerSalt,
root = root,
rootEpoch = rootEpoch,
controlPk = controlPk,
controlRoot = controlRoot,
heldRoots = heldRoots,
privateChannels = privateChannels,
relays = relays,
@@ -99,6 +99,20 @@ data class AuthorityResolver private constructor(
bit: Int,
): Boolean = !isBanned(pubKey) && effectivePermissions(pubKey).has(bit)
/**
* True if the member is **staff** (CORD-04 §3): the owner, or any non-banned
* holder of a Control-writing bit ([ConcordPermissions.STAFF_BITS]) the set
* that holds the `control_root` (CORD-02 §2).
*/
fun isStaff(pubKey: String): Boolean = isOwner(pubKey) || (!isBanned(pubKey) && effectivePermissions(pubKey).hasAny(ConcordPermissions.STAFF_BITS))
/**
* The staff roster (lowercase hex): the owner plus every role-holder whose
* effective permissions carry a staff bit. This is the recipient set that gets
* the `control_root` in a base rotation's 136-byte blobs (CORD-06 §1).
*/
fun staffMembers(): Set<String> = memberRoles.keys.filterTo(hashSetOf(ownerLower)) { isStaff(it) }
/**
* Whether [actor] may take the action guarded by permission [bit] against
* [target]. Requires: actor not banned, actor holds [bit], the owner is never
@@ -42,6 +42,9 @@ value class ConcordPermissions(
/** True if every bit set in [other] is also set here (used for role-vs-actor checks). */
fun hasAll(other: ConcordPermissions): Boolean = (bits and other.bits) == other.bits
/** True if at least one bit set in [other] is also set here. */
fun hasAny(other: ConcordPermissions): Boolean = (bits and other.bits) != 0uL
infix fun union(other: ConcordPermissions): ConcordPermissions = ConcordPermissions(bits or other.bits)
fun with(bit: Int): ConcordPermissions = ConcordPermissions(bits or (1uL shl bit))
@@ -71,7 +74,21 @@ value class ConcordPermissions(
const val VIEW_AUDIT_LOG = 8
const val MENTION_EVERYONE = 9
// bits 10-12 reserved
// bits 10 and 12 reserved (MANAGE_EMOJI, MANAGE_EVENTS)
const val PIN_MESSAGES = 11
/**
* The **staff** bits (CORD-04 §3): the six permissions whose actions land as
* Control Plane editions. A member holding any of them, plus always the
* owner, is staff the set that holds the `control_root` (CORD-02 §2).
* `KICK` writes to the Guestbook and `MANAGE_MESSAGES` to Chat planes, so
* neither is here. The spec's list is **normative**: a future CORD
* introducing a permission whose actions are Control editions MUST amend it
* explicitly, so no implementation judges membership of the set for itself
* extend this constant only when the spec's list changes.
*/
val STAFF_BITS: ConcordPermissions get() = of(MANAGE_ROLES, MANAGE_CHANNELS, MANAGE_METADATA, BAN, CREATE_INVITE, PIN_MESSAGES)
fun of(vararg bits: Int): ConcordPermissions {
var acc = 0uL
@@ -92,11 +92,20 @@ data class RoleEntity(
* A Grant's content (CORD-04): maps a [member] to the set of [roleIds] they hold.
* Honored only if the granting actor outranks every assigned Role and the chain
* terminates at the owner (see [AuthorityResolver]).
*
* A staff-making Grant also delivers the Control Plane write secret in
* [controlWrap] (CORD-04 §3): the `control_root` NIP-44-encrypted under the
* grantermember pairwise conversation key, its plaintext the fixed-width 40
* bytes `epoch_be[8] control_root[32]` (see [ControlRootWrap]). Delivery, never
* authority every reader but the member treats it as opaque bytes, and the
* member adopts the secret only if it derives to the `control_pk` they hold for
* the named epoch.
*/
@Serializable
data class GrantEntity(
val member: String = "",
@SerialName("role_ids") val roleIds: List<String> = emptyList(),
@SerialName("control_wrap") val controlWrap: String? = null,
)
/**
@@ -0,0 +1,115 @@
/*
* 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.cord04Roles
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/** The opened `control_wrap` payload: the `control_root` delivered for [epoch]. */
class DeliveredControlRoot(
val epoch: Long,
val controlRoot: ByteArray,
)
/**
* The staff write-key delivery riding a Grant (CORD-04 §3): a staff-making Grant
* carries the current `control_root` in [GrantEntity.controlWrap], NIP-44-encrypted
* under the grantermember pairwise conversation key one ECDH either side can
* compute, so a NIP-46 bunker account opens it with a single `nip44Decrypt`.
*
* The plaintext is fixed-width, the rekey-blob discipline (CORD-06 §1):
* `epoch_be[8] control_root[32]`, 40 bytes. The epoch rides *inside* the
* ciphertext because staleness is structural compaction re-wraps a Grant head
* verbatim across Refoundings, so a folded head can carry a wrap minted for a
* prior epoch's key. Harmless: the recipient adopts the secret only if it derives
* to exactly the `control_pk` they hold for the named epoch ([derivesTo]), and any
* mismatch is dropped, never adopted.
*/
object ControlRootWrap {
/** `epoch_be[8] ‖ control_root[32]` */
const val SIZE = 40
fun encodePlaintext(
epoch: Long,
controlRoot: ByteArray,
): ByteArray {
require(controlRoot.size == 32) { "controlRoot must be 32 bytes" }
val out = ByteArray(SIZE)
ConcordKeyDerivation.writeBe64(out, 0, epoch)
controlRoot.copyInto(out, 8)
return out
}
fun decodePlaintext(bytes: ByteArray): DeliveredControlRoot? {
if (bytes.size != SIZE) return null
var epoch = 0L
for (i in 0 until 8) epoch = (epoch shl 8) or (bytes[i].toLong() and 0xFF)
return DeliveredControlRoot(epoch, bytes.copyOfRange(8, SIZE))
}
/**
* Builds the `control_wrap` value a staff-making Grant carries: the 40-byte
* plaintext, base64'd, then NIP-44-encrypted by [granterSigner] to
* [memberPubKey]. A staff-making edition MUST carry a wrap fresh for the
* current [epoch].
*/
@OptIn(ExperimentalEncodingApi::class)
suspend fun build(
granterSigner: NostrSigner,
memberPubKey: HexKey,
epoch: Long,
controlRoot: ByteArray,
): String = granterSigner.nip44Encrypt(Base64.Default.encode(encodePlaintext(epoch, controlRoot)), memberPubKey)
/**
* Opens a received `control_wrap` with the member's own [memberSigner] against
* the Grant edition's author ([granterPubKey]). Null on any failure a garbage
* wrap is attributable griefing, nothing worse. The caller MUST still gate
* adoption on [derivesTo] against the `control_pk` it holds for the returned
* epoch.
*/
@OptIn(ExperimentalEncodingApi::class)
suspend fun openOrNull(
controlWrap: String,
memberSigner: NostrSigner,
granterPubKey: HexKey,
): DeliveredControlRoot? =
try {
decodePlaintext(Base64.Default.decode(memberSigner.nip44Decrypt(controlWrap, granterPubKey)))
} catch (_: Exception) {
null
}
/**
* The adoption check (CORD-04 §3): true when [controlRoot] derives to exactly
* the [heldControlPk] this member holds for [epoch] (CORD-02 §5). A mismatch is
* dropped, never adopted the check fails closed.
*/
fun derivesTo(
controlRoot: ByteArray,
communityId: ByteArray,
epoch: Long,
heldControlPk: HexKey,
): Boolean = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, epoch).publicKeyHex == heldControlPk.lowercase()
}
@@ -58,6 +58,19 @@ class CommunityInvite(
@SerialName("owner_salt") val ownerSalt: String,
@SerialName("community_root") val communityRoot: String,
@SerialName("root_epoch") val rootEpoch: Long = 0,
/**
* The Control Plane's signer pubkey at [rootEpoch] (CORD-02 §5): subscribe,
* verify, read never write. Absent = a legacy, pre-split Community; the
* joiner folds Control at the legacy address instead (CORD-06 §3).
*
* Taken on trust in a way the other fields are not: it derives from a secret
* the joiner will never hold, so nothing in the bundle can prove it. A wrong
* one is eclipse-class self-harm by the inviter (a stale or empty Control
* read), the same trust class as a hostile [relays] list never forged
* authority, since every edition still verifies against the owner-rooted
* Roster, and a later base rotation re-delivers the true key.
*/
@SerialName("control_pk") val controlPk: String? = null,
val channels: List<InviteChannel> = emptyList(),
val relays: List<String> = emptyList(),
val name: String = "",
@@ -76,7 +76,10 @@ object ConcordStrandedRecovery {
): ConcordCommunityListEntry? {
if (!isStranded(entry, bundle)) return null
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch }
// Bank the epoch we are leaving with its control_pk, so its Control Plane
// stays re-subscribable for the anti-rollback floor (a split epoch's address
// is held, never derivable — CORD-02 §2).
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }
return ConcordCommunityListEntry(
id = entry.id,
@@ -84,6 +87,9 @@ object ConcordStrandedRecovery {
ownerSalt = entry.ownerSalt,
root = bundle.communityRoot,
rootEpoch = bundle.rootEpoch,
// The re-minted bundle carries the new epoch's control_pk (CORD-05 §1);
// absent means the community is (still) legacy at that epoch.
controlPk = bundle.controlPk,
heldRoots = held,
privateChannels = entry.privateChannels,
relays = if (bundle.relays.isNotEmpty()) bundle.relays else entry.relays,
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord06Rekey
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -34,25 +35,48 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
/**
* The events a Refounding produces (CORD-06 §3): the [controlWraps] (the current
* Control Plane, compacted to its per-entity head editions and re-sealed under the
* fresh [newRoot] at [newEpoch]) and the [rekeyWraps] (kind-3303 base-rotation
* blobs, sealed under the **prior** root, that deliver [newRoot] to every retained
* member and to nobody else). Publish [controlWraps] first (the new epoch's state)
* then [rekeyWraps] (the key that unlocks it).
* Control Plane, compacted to its per-entity head editions and re-sealed at the
* new epoch's split Control address signed by the fresh `control_root`-derived
* signer, readable under the fresh [newRoot]-derived read key) and the
* [rekeyWraps] (kind-3303 base-rotation blobs, sealed under the **prior** root,
* that deliver [newRoot] + the new `control_pk` to every retained member and
* the [newControlRoot] secret to staff and to nobody else). Publish
* [controlWraps] first (the new epoch's state) then [rekeyWraps] (the key that
* unlocks it).
*/
class RefoundingBuild(
val newRoot: ByteArray,
/** The fresh staff write key, minted beside [newRoot] (CORD-02 §2). */
val newControlRoot: ByteArray,
val newEpoch: Long,
/** The new epoch's split Control Plane keys (rotator view: signer held). */
val newControlKeys: ControlPlaneKeys,
val controlWraps: List<Event>,
val rekeyWraps: List<Event>,
)
) {
/** The new epoch's Control Plane address, delivered to every member in the base blobs. */
val newControlPk: ByteArray get() = newControlKeys.address.hexToByteArray()
}
/** A retained member's decrypted rekey result: the [newRoot] delivered at [newEpoch] by [rotator]. */
/**
* A retained member's decrypted rekey result: the [newRoot] delivered at
* [newEpoch] by [rotator], plus the next epoch's Control Plane keys the
* [newControlPk] every member's blob carries, and, for a staff recipient, the
* [newControlRoot] write secret (CORD-06 §1). A null [newControlPk] marks a
* legacy, pre-split 72-byte rotation (CORD-06 §3): its acceptor folds that
* epoch's Control at the legacy address, honored when reading old rotations and
* never minted by a compliant Rotator.
*/
class ReceivedRefounding(
val newRoot: ByteArray,
val newEpoch: Long,
val rotator: HexKey,
)
val newControlPk: ByteArray? = null,
val newControlRoot: ByteArray? = null,
) {
/** True when this was a legacy pre-split rotation (72-byte base blob). */
val legacy: Boolean get() = newControlPk == null
}
/**
* Whole-community Refounding (CORD-06 §3): rotate `community_root` to sever a
@@ -60,37 +84,50 @@ class ReceivedRefounding(
* derive from the root, so rolling it rotates every plane at once; Private Channels
* (independently keyed) are rekeyed separately and are not handled here.
*
* A compliant Rotator performing any base rotation MUST mint the `control_root`
* split (CORD-02 §2) a fresh secret beside the new root, both riding the same
* blobs so a legacy Community upgrades as a side effect of its next Refounding,
* with nobody deciding to.
*
* The builder is pure the caller sources the retained-recipient set (from the
* Guestbook membership minus the removed/banned) and owns publish + persistence.
* All crypto is signer-based so a NIP-46 bunker owner can refound without exposing
* a raw key.
* Guestbook membership minus the removed/banned) and the staff subset (the folded
* Roster's `staffMembers()`, CORD-04 §3) and owns publish + persistence. All
* crypto is signer-based so a NIP-46 bunker owner can refound without exposing a
* raw key.
*/
object ConcordRefounding {
/**
* Builds a Refounding: compacts the Control Plane under [newRoot] and mints the
* base-rotation rekey blobs delivering [newRoot] to [recipientsXOnly].
* Builds a Refounding: compacts the Control Plane onto the new epoch's split
* Control address and mints the base-rotation rekey blobs delivering [newRoot]
* + the new `control_pk` to [recipientsXOnly] (the [staffXOnly] subset also
* receiving [newControlRoot]).
*
* @param priorRoot the community_root being rotated out (at [rootEpoch])
* @param newRoot the freshly generated 32-byte community_root
* @param newControlRoot the freshly minted 32-byte staff write key (CORD-02 §2)
* @param priorControlWraps the current Control Plane's kind-1059 wraps (any subset that folds)
* @param priorControlKey the Control Plane group key at [rootEpoch]
* @param priorControlKeys the Control Plane keys at [rootEpoch] (split or legacy)
* @param recipientsXOnly the retained members' x-only pubkeys (hex) to re-key
* @param staffXOnly the subset of [recipientsXOnly] that is staff (owner + Control-writing
* permission holders, CORD-04 §3) and receives the 136-byte blob
*/
suspend fun build(
rotatorSigner: NostrSigner,
communityId: ByteArray,
priorRoot: ByteArray,
newRoot: ByteArray,
newControlRoot: ByteArray,
rootEpoch: Long,
priorControlWraps: List<Event>,
priorControlKey: GroupKey,
priorControlKeys: ControlPlaneKeys,
recipientsXOnly: List<HexKey>,
staffXOnly: Set<HexKey>,
createdAt: Long,
): RefoundingBuild {
val newEpoch = rootEpoch + 1
val newControlKey = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, newEpoch)
val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot)
val controlWraps = compactControlPlane(priorControlWraps, priorControlKey, newControlKey)
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys)
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch)
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
@@ -99,33 +136,41 @@ object ConcordRefounding {
rotatorSigner = rotatorSigner,
baseRekeyKey = baseRekeyKey,
recipientsXOnly = recipientsXOnly,
staffXOnly = staffXOnly,
newRoot = newRoot,
newControlPk = newControlKeys.address.hexToByteArray(),
newControlRoot = newControlRoot,
newEpoch = newEpoch,
prevEpoch = rootEpoch,
prevCommit = prevCommit,
createdAt = createdAt,
)
return RefoundingBuild(newRoot, newEpoch, controlWraps, rekeyWraps)
return RefoundingBuild(newRoot, newControlRoot, newEpoch, newControlKeys, controlWraps, rekeyWraps)
}
/**
* Compacts [priorWraps] into a slim snapshot re-published under [newControlKey]
* Compacts [priorWraps] into a slim snapshot re-published under [newControlKeys]
* (CORD-06 §3): keep only the head (highest-version) edition per entity and
* re-wrap its **original plaintext seal** which carries the original author's
* signature under the new root. Because Control Plane seals are plaintext
* (CORD-02 §5), re-encryption preserves those signatures, so a fresh joiner
* verifies the compacted state exactly as it verified the full chain.
* signature at the new epoch's Control address. Because Control Plane seals
* are plaintext (CORD-02 §5), re-encryption preserves those signatures, so a
* fresh joiner verifies the compacted state exactly as it verified the full
* chain. [priorControlKeys] may be legacy (a pre-split epoch's compaction is
* exactly how a Community upgrades to the split) or split; [newControlKeys]
* must hold the new signer. A Rotator MUST NOT mirror editions to the new
* epoch's legacy-derived address to appease stale readers the mirror
* re-opens exactly the member-writable surface the split closes.
*/
fun compactControlPlane(
priorWraps: List<Event>,
priorControlKey: GroupKey,
newControlKey: GroupKey,
priorControlKeys: ControlPlaneKeys,
newControlKeys: ControlPlaneKeys,
): List<Event> {
// entity coordinate -> (head edition, its verified seal)
val heads = HashMap<String, Pair<ControlEdition, Event>>()
for (wrap in priorWraps) {
val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKey) ?: continue
val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKeys) ?: continue
val edition = ControlEdition.fromRumor(opened.rumor) ?: continue
val coord = edition.entityKind.wire + ":" + edition.entityIdHex
val current = heads[coord]
@@ -133,12 +178,14 @@ object ConcordRefounding {
heads[coord] = edition to opened.seal
}
}
return heads.values.map { (_, seal) -> ConcordStreamEnvelope.wrapSeal(seal, newControlKey, createdAt = seal.createdAt) }
return heads.values.map { (_, seal) -> ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt) }
}
/**
* Mints the base-rotation rekey blobs delivering [newRoot] to [recipientsXOnly],
* chunked at [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal,
* Mints the base-rotation rekey blobs delivering [newRoot] + [newControlPk] to
* [recipientsXOnly] the [staffXOnly] subset also receiving [newControlRoot]
* in the 136-byte staff form (CORD-06 §1) chunked at
* [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal,
* rotator-signed) on the [baseRekeyKey] address so every current member who
* precomputes that address from the prior root receives it live.
*/
@@ -146,16 +193,28 @@ object ConcordRefounding {
rotatorSigner: NostrSigner,
baseRekeyKey: GroupKey,
recipientsXOnly: List<HexKey>,
staffXOnly: Set<HexKey>,
newRoot: ByteArray,
newControlPk: ByteArray,
newControlRoot: ByteArray,
newEpoch: Long,
prevEpoch: Long,
prevCommit: HexKey,
createdAt: Long,
): List<Event> {
if (recipientsXOnly.isEmpty()) return emptyList()
val staffLower = staffXOnly.mapTo(HashSet()) { it.lowercase() }
val blobs =
recipientsXOnly.map { recipient ->
ConcordRekey.blobForSigner(rotatorSigner, recipient.hexToByteArray(), ConcordRekey.ROOT_SCOPE, newEpoch, newRoot)
ConcordRekey.blobForSigner(
rotatorSigner = rotatorSigner,
recipientXOnly = recipient.hexToByteArray(),
scopeId = ConcordRekey.ROOT_SCOPE,
newEpoch = newEpoch,
newKey = newRoot,
newControlPk = newControlPk,
newControlRoot = if (recipient.lowercase() in staffLower) newControlRoot else null,
)
}
val chunks = blobs.chunked(ConcordRekey.MAX_BLOBS_PER_CHUNK)
val total = chunks.size
@@ -170,15 +229,19 @@ object ConcordRefounding {
* Receives a base rotation for the member behind [recipientSigner]: opens the
* kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]),
* verifies each is a well-formed root rotation to [newEpoch] whose `prevcommit`
* continues the [priorRoot] the member holds, and returns the delivered new root
* (with the rotator's real pubkey, so the caller can authorize it against the
* folded roster). Null if no chunk carries this member's blob which only means
* continues the [priorRoot] the member holds, and returns the delivered new
* root and Control Plane keys (with the rotator's real pubkey, so the caller
* can authorize it against the folded roster). A staff blob's delivered secret
* must derive to exactly the delivered `control_pk` (CORD-02 §5) a
* mismatched pair is refused rather than adopting a plane split from its
* readers. Null if no chunk carries this member's blob which only means
* "removed" once the caller confirms it holds every chunk of the rotation.
*/
suspend fun findNewRoot(
wraps: List<Event>,
baseRekeyKey: GroupKey,
recipientSigner: NostrSigner,
communityId: ByteArray,
priorRoot: ByteArray,
rootEpoch: Long,
): ReceivedRefounding? {
@@ -195,8 +258,16 @@ object ConcordRefounding {
val blobs = ConcordRekey.decodeContent(rumor.content)
val rotatorXOnly = opened.author.hexToByteArray()
val newRoot = ConcordRekey.findNewKeyWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue
return ReceivedRefounding(newRoot, newEpoch, opened.author)
val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue
val controlRoot = payload.newControlRoot
val controlPk = payload.newControlPk
if (controlRoot != null && controlPk != null) {
// The staff derive-check (CORD-06 §1): refuse a pair whose secret does not
// derive to the pk the other members were handed — fails closed.
val derived = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, newEpoch).publicKey
if (!derived.contentEquals(controlPk)) continue
}
return ReceivedRefounding(payload.newKey, newEpoch, opened.author, controlPk, controlRoot)
}
return null
}
@@ -38,10 +38,12 @@ import kotlin.io.encoding.ExperimentalEncodingApi
*
* 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 rotatorrecipient 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
* pseudonym (public-input HKDF), and its `wrapped` field is the fixed-width
* [RekeyPayload] (base64 NIP-44 under the rotatorrecipient pairwise key)
* 72 bytes for a channel rotation, 104/136 for a base rotation's member/staff
* forms carrying the next epoch's Control Plane keys (CORD-02 §2). A recipient
* computes their own locator, finds the matching blob, and decrypts the new
* key(s); 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.
@@ -57,7 +59,9 @@ object ConcordRekey {
val ROOT_SCOPE: ByteArray = ByteArray(32)
/**
* Builds a rekey blob delivering [newKey] to one recipient.
* Builds a rekey blob delivering [newKey] to one recipient. On a base rotation
* pass [newControlPk] (every member) and, for a staff recipient, also
* [newControlRoot] (CORD-06 §1) the widths select the 104/136-byte forms.
*
* @param rotatorPrivKey the rotator's private key (their real identity)
* @param rotatorXOnly the rotator's x-only pubkey
@@ -71,9 +75,11 @@ object ConcordRekey {
scopeId: ByteArray,
newEpoch: Long,
newKey: ByteArray,
newControlPk: ByteArray? = null,
newControlRoot: ByteArray? = null,
): RekeyBlob {
val locator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey()
val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey).encode())
val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey, newControlPk, newControlRoot).encode())
val convKey = Nip44.v2.getConversationKey(rotatorPrivKey, recipientXOnly)
val wrapped = Nip44.v2.encrypt(payloadB64, convKey).encodePayload()
return RekeyBlob(locator, wrapped)
@@ -125,38 +131,54 @@ object ConcordRekey {
scopeId: ByteArray,
newEpoch: Long,
newKey: ByteArray,
newControlPk: ByteArray? = null,
newControlRoot: ByteArray? = null,
): RekeyBlob {
val rotatorXOnly = rotatorSigner.pubKey.hexToByteArray()
val locator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey()
val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey).encode())
val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey, newControlPk, newControlRoot).encode())
val wrapped = rotatorSigner.nip44Encrypt(payloadB64, recipientXOnly.toHexKey())
return RekeyBlob(locator, wrapped)
}
/**
* Finds the recipient's rotated key like [findNewKey], but decrypts the blob via
* [recipientSigner] (bunker-compatible) rather than a raw private key.
* Finds the recipient's whole decrypted [RekeyPayload] (scope and epoch already
* verified against the expectation) via [recipientSigner], or null if no blob
* matches or it fails to open/verify. Base rotations need the full payload
* the delivered `new_control_pk` / `new_control_root` ride beside the key.
*/
@OptIn(ExperimentalEncodingApi::class)
suspend fun findNewKeyWithSigner(
suspend fun findPayloadWithSigner(
blobs: List<RekeyBlob>,
recipientSigner: NostrSigner,
rotatorXOnly: ByteArray,
scopeId: ByteArray,
newEpoch: Long,
): ByteArray? {
): RekeyPayload? {
val recipientXOnly = recipientSigner.pubKey.hexToByteArray()
val myLocator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey()
val blob = blobs.firstOrNull { it.locator == myLocator } ?: return null
return try {
val payload = RekeyPayload.decode(Base64.Default.decode(recipientSigner.nip44Decrypt(blob.wrapped, rotatorXOnly.toHexKey()))) ?: return null
if (!payload.scopeId.contentEquals(scopeId) || payload.epoch != newEpoch) return null
payload.newKey
payload
} catch (_: Exception) {
null
}
}
/**
* Finds the recipient's rotated key like [findNewKey], but decrypts the blob via
* [recipientSigner] (bunker-compatible) rather than a raw private key.
*/
suspend fun findNewKeyWithSigner(
blobs: List<RekeyBlob>,
recipientSigner: NostrSigner,
rotatorXOnly: ByteArray,
scopeId: ByteArray,
newEpoch: Long,
): ByteArray? = findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, scopeId, newEpoch)?.newKey
/**
* 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,
@@ -26,8 +26,8 @@ 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 rotatorrecipient
* pairwise key).
* fixed-width [RekeyPayload], base64'd then NIP-44-encrypted under the
* rotatorrecipient pairwise key).
*/
@Serializable
class RekeyBlob(
@@ -36,33 +36,77 @@ class RekeyBlob(
)
/**
* 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].
* A rekey blob's plaintext (CORD-06 §1), fixed-width per form the width
* declaring the form:
*
* - **72 bytes** `scope_id[32] epoch_be8 new_key[32]`: a Channel
* rotation's blob, or a legacy pre-split *base* rotation (honored when reading
* old epochs, never minted anew CORD-06 §3).
* - **104 bytes** ` new_control_pk[32]`: a base rotation's member blob,
* also carrying the next epoch's Control Plane address (CORD-02 §2).
* - **136 bytes** ` new_control_root[32]`: a base rotation's staff blob,
* additionally delivering the write secret (CORD-04 §3 staff).
*
* Any other width is malformed and the blob is dropped ([decode] returns null).
* The scope and epoch live *inside* the ciphertext so a recipient can verify them
* against the event's tags before adopting anything, making a blob unspliceable;
* a staff recipient additionally requires that [newControlRoot] derive to exactly
* [newControlPk] (CORD-02 §5) before adopting the pair.
*/
class RekeyPayload(
val scopeId: ByteArray,
val epoch: Long,
val newKey: ByteArray,
/** The next epoch's `control_pk` on a base rotation; null on a channel or legacy blob. */
val newControlPk: ByteArray? = null,
/** The next epoch's `control_root` on a staff base blob; null otherwise. */
val newControlRoot: ByteArray? = null,
) {
init {
require(newControlRoot == null || newControlPk != null) { "a control_root is only ever delivered beside its control_pk" }
}
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)
require(newControlPk == null || newControlPk.size == 32) { "newControlPk must be 32 bytes" }
require(newControlRoot == null || newControlRoot.size == 32) { "newControlRoot must be 32 bytes" }
val size =
when {
newControlRoot != null -> SIZE_BASE_STAFF
newControlPk != null -> SIZE_BASE_MEMBER
else -> SIZE_CHANNEL
}
val out = ByteArray(size)
scopeId.copyInto(out, 0)
ConcordKeyDerivation.writeBe64(out, 32, epoch)
newKey.copyInto(out, 40)
newControlPk?.copyInto(out, 72)
newControlRoot?.copyInto(out, 104)
return out
}
companion object {
const val SIZE = 72
/** A Channel rotation's blob — also the legacy pre-split base form (CORD-06 §3). */
const val SIZE_CHANNEL = 72
/** A base rotation's member blob: `… ‖ new_control_pk[32]`. */
const val SIZE_BASE_MEMBER = 104
/** A base rotation's staff blob: `… ‖ new_control_root[32]`. */
const val SIZE_BASE_STAFF = 136
fun decode(bytes: ByteArray): RekeyPayload? {
if (bytes.size != SIZE) return null
if (bytes.size != SIZE_CHANNEL && bytes.size != SIZE_BASE_MEMBER && bytes.size != SIZE_BASE_STAFF) 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))
return RekeyPayload(
scopeId = bytes.copyOfRange(0, 32),
epoch = epoch,
newKey = bytes.copyOfRange(40, 72),
newControlPk = if (bytes.size >= SIZE_BASE_MEMBER) bytes.copyOfRange(72, 104) else null,
newControlRoot = if (bytes.size >= SIZE_BASE_STAFF) bytes.copyOfRange(104, 136) else null,
)
}
}
}
@@ -182,13 +182,33 @@ object ConcordKeyDerivation {
// ---- Plane keys (CORD-02) -------------------------------------------------
/** The Control Plane address for a community at [epoch] (holders of the root only). */
/**
* The Control Plane *read* key for a community at [epoch] (CORD-02 §5): its
* `conversationKey` encrypts the wraps, so every `community_root` holder can read.
*
* On a pre-split (legacy) epoch this derivation alone was the plane its pk the
* address and wrap signer, its sk held by every member. That use is retained for
* reading legacy epochs (CORD-06 §3); a split epoch's address comes from
* [controlSignerKey] instead.
*/
fun controlPlaneKey(
communityRoot: ByteArray,
communityId: ByteArray,
epoch: Long,
): GroupKey = groupKey(ConcordLabels.CONTROL, communityRoot, communityId, epoch)
/**
* The Control Plane *signer* for a community at [epoch] (CORD-02 §5): its pk is
* the plane's address (`control_pk`) and its sk derivable only from the
* staff-held `control_root` signs the wraps. Possession is a spam gate, never
* authority: every edition is still judged by its sealed actor's Roster rank.
*/
fun controlSignerKey(
controlRoot: ByteArray,
communityId: ByteArray,
epoch: Long,
): GroupKey = groupKey(ConcordLabels.CONTROL_SIGNER, controlRoot, communityId, epoch)
/** The Guestbook Plane address for a community at [epoch]. */
fun guestbookPlaneKey(
communityRoot: ByteArray,
@@ -35,9 +35,19 @@ object ConcordLabels {
/** Per-Channel Chat Plane key (CORD-03). */
const val CHANNEL = "concord/channel"
/** Control Plane key (CORD-02). */
/**
* Control Plane *read* key (CORD-02 §5): community_root-derived, its conv_key
* encrypts the wraps. Pre-split epochs used its pk/sk as the plane's address and
* signer too that use is retained for reading legacy epochs (CORD-06 §3).
*/
const val CONTROL = "concord/control"
/**
* Control Plane signer (CORD-02 §5): control_root-derived, its pk is the plane's
* address and its staff-only sk signs the wraps (CORD-01, Write-Restricted Streams).
*/
const val CONTROL_SIGNER = "concord/control-signer"
/** Guestbook Plane key (CORD-02). */
const val GUESTBOOK = "concord/guestbook"
@@ -0,0 +1,127 @@
/*
* 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.crypto
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
/**
* The Control Plane's key material at one epoch (CORD-02 §5).
*
* The plane's stream key is split (CORD-01, Write-Restricted Streams): the
* address-and-signer keypair derives from the staff-held `control_root`, while the
* wraps' content is encrypted under the `community_root`-derived read key every
* member holds. So what an account holds depends on its standing:
*
* - **Member**: the [address] (`control_pk`, held never derivable) plus the
* [readKey]. Enough to subscribe, verify wrap signatures, and decrypt; [signer]
* is null and [canWrite] false.
* - **Staff / owner**: additionally the [signer] (derived from the `control_root`),
* whose sk mints wraps that verify at the address.
* - **Legacy epoch** (pre-split, CORD-06 §3): the `concord/control` derivation
* alone was the plane its pk the address and signer, every member holding
* both. [legacy] is true and [signer] == [readKey].
*/
class ControlPlaneKeys(
/** The plane's stream address (`control_pk` on a split epoch): subscribe + verify. */
val address: HexKey,
/** The `community_root`-derived read key; its `conversationKey` opens the wraps. */
val readKey: GroupKey,
/** The keypair whose sk signs wraps at [address]. Null when this account cannot write. */
val signer: GroupKey?,
/** True for a pre-split epoch keyed by the legacy member-held derivation. */
val legacy: Boolean,
) {
/** True when this account holds the write key for the plane. */
val canWrite: Boolean get() = signer != null
companion object {
/**
* A pre-split epoch's Control Plane: the legacy `concord/control` derivation
* is address, signer, and read key at once every member holds all three.
*/
fun legacy(
communityRoot: ByteArray,
communityId: ByteArray,
epoch: Long,
): ControlPlaneKeys {
val key = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, epoch)
return ControlPlaneKeys(key.publicKeyHex, key, signer = key, legacy = true)
}
/**
* A split epoch as a regular member holds it: the delivered [controlPk]
* (invite / community list / base rekey blob, CORD-02 §2) plus the derived
* read key. Read-only a member cannot mint a wrap at the address.
*/
fun forMember(
communityRoot: ByteArray,
communityId: ByteArray,
epoch: Long,
controlPk: HexKey,
): ControlPlaneKeys =
ControlPlaneKeys(
address = controlPk.lowercase(),
readKey = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, epoch),
signer = null,
legacy = false,
)
/**
* A split epoch as staff holds it: the signer derives from the held
* [controlRoot], yielding the address and the write key together.
*/
fun forStaff(
communityRoot: ByteArray,
communityId: ByteArray,
epoch: Long,
controlRoot: ByteArray,
): ControlPlaneKeys {
val signer = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, epoch)
return ControlPlaneKeys(
address = signer.publicKeyHex,
readKey = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, epoch),
signer = signer,
legacy = false,
)
}
/**
* Dispatches on what the account holds: the `control_root` secret (staff),
* only the `control_pk` (member), or neither a legacy, pre-split epoch
* (CORD-06 §3). A held [controlRoot] wins over a held [controlPk]: the pk it
* derives is the plane by definition (a delivered secret is only ever adopted
* after the derive-check, CORD-04 §3).
*/
fun of(
communityRoot: ByteArray,
communityId: ByteArray,
epoch: Long,
controlPk: HexKey? = null,
controlRoot: HexKey? = null,
): ControlPlaneKeys =
when {
controlRoot != null -> forStaff(communityRoot, communityId, epoch, controlRoot.hexToByteArray())
controlPk != null -> forMember(communityRoot, communityId, epoch, controlPk)
else -> legacy(communityRoot, communityId, epoch)
}
}
}
@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.quartz.concord.envelope
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
@@ -96,14 +98,45 @@ object ConcordStreamEnvelope {
stream: GroupKey,
ephemeral: Boolean = false,
createdAt: Long = TimeUtils.now(),
): Event = wrapSeal(seal, stream, stream.conversationKey, ephemeral, createdAt)
/**
* Write-restricted variant (CORD-01, Write-Restricted Streams): the wrap is
* signed by [signerKey] (its pk the stream address, its sk held by the writers
* alone) while the content is encrypted under [readConversationKey], the second
* shared key the full readership holds. Concord's Control Plane wraps this way
* on a split epoch (CORD-02 §5).
*/
fun wrapSeal(
seal: Event,
signerKey: GroupKey,
readConversationKey: ByteArray,
ephemeral: Boolean = false,
createdAt: Long = TimeUtils.now(),
): Event {
val streamSigner = NostrSignerSync(KeyPair(privKey = stream.secretKey))
val content = Nip44.v2.encrypt(seal.toJson(), stream.conversationKey).encodePayload()
val streamSigner = NostrSignerSync(KeyPair(privKey = signerKey.secretKey))
val content = Nip44.v2.encrypt(seal.toJson(), readConversationKey).encodePayload()
val ephemeralP = KeyPair().pubKey.toHexKey()
val kind = if (ephemeral) KIND_WRAP_EPHEMERAL else KIND_WRAP
return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)), content)
}
/**
* Wraps [seal] onto the Control Plane described by [keys]: signed by its signer
* (which the holder must have throws when [ControlPlaneKeys.canWrite] is
* false), encrypted under its read key. On a legacy epoch signer == read key
* and this is the classic single-key wrap.
*/
fun wrapSeal(
seal: Event,
keys: ControlPlaneKeys,
ephemeral: Boolean = false,
createdAt: Long = TimeUtils.now(),
): Event {
val signer = requireNotNull(keys.signer) { "This account cannot write to the Control Plane: control_root not held (CORD-02 §2)" }
return wrapSeal(seal, signer, keys.readKey.conversationKey, ephemeral, createdAt)
}
/** Convenience: [seal] then [wrapSeal] in one call. */
suspend fun wrap(
rumor: Event,
@@ -114,6 +147,20 @@ object ConcordStreamEnvelope {
createdAt: Long = TimeUtils.now(),
): Event = wrapSeal(seal(rumor, stream, authorSigner, encrypted), stream, ephemeral, createdAt)
/**
* Convenience for the Control Plane: seals under [keys]' read key (an encrypted
* seal's rumor must decrypt for every reader, not only writers) and wraps with
* its signer. Throws when the account cannot write (see [wrapSeal]).
*/
suspend fun wrap(
rumor: Event,
keys: ControlPlaneKeys,
authorSigner: NostrSigner,
encrypted: Boolean,
ephemeral: Boolean = false,
createdAt: Long = TimeUtils.now(),
): Event = wrapSeal(seal(rumor, keys.readKey, authorSigner, encrypted), keys, ephemeral, createdAt)
/**
* Opens a stream [wrap] for the [stream] plane and returns the verified author
* rumor, or throws if any layer fails to validate:
@@ -129,16 +176,31 @@ object ConcordStreamEnvelope {
fun open(
wrap: Event,
stream: GroupKey,
): OpenedStreamEvent = open(wrap, stream.publicKeyHex, stream.conversationKey)
/**
* Write-restricted variant (CORD-01, Write-Restricted Streams): opening takes
* only the stream [address] (the writers' pubkey, held by every reader) and the
* [readConversationKey] never the signer's secret. `wrap.verify()` checks the
* signature against `wrap.pubkey`, which the address equality pins to the
* writers' key, so a wrap minted by anyone else fails here. A verifying wrap
* proves only that *a* writer published it; the seal's actor stays the sole
* authority (CORD-04).
*/
fun open(
wrap: Event,
address: HexKey,
readConversationKey: ByteArray,
): OpenedStreamEvent {
require(wrap.kind == KIND_WRAP || wrap.kind == KIND_WRAP_EPHEMERAL) {
"Not a Concord stream wrap: kind ${wrap.kind}"
}
require(wrap.pubKey == stream.publicKeyHex) {
"Wrap author ${wrap.pubKey} is not the stream address ${stream.publicKeyHex}"
require(wrap.pubKey == address) {
"Wrap author ${wrap.pubKey} is not the stream address $address"
}
require(wrap.verify()) { "Wrap signature/id is invalid" }
val seal = Event.fromJson(Nip44.v2.decrypt(wrap.content, stream.conversationKey))
val seal = Event.fromJson(Nip44.v2.decrypt(wrap.content, readConversationKey))
require(seal.kind == KIND_SEAL_ENCRYPTED || seal.kind == KIND_SEAL_PLAINTEXT) {
"Not a Concord seal: kind ${seal.kind}"
}
@@ -146,7 +208,7 @@ object ConcordStreamEnvelope {
val rumorJson =
if (seal.kind == KIND_SEAL_ENCRYPTED) {
Nip44.v2.decrypt(seal.content, stream.conversationKey)
Nip44.v2.decrypt(seal.content, readConversationKey)
} else {
seal.content
}
@@ -160,17 +222,40 @@ object ConcordStreamEnvelope {
return OpenedStreamEvent(rumor, seal.kind, seal.pubKey, seal)
}
/**
* Opens a Control Plane wrap with [keys] (split or legacy): verified against the
* plane's address, decrypted under its read key. Needs no write key, so a regular
* member reads exactly as staff does (CORD-02 §5).
*/
fun open(
wrap: Event,
keys: ControlPlaneKeys,
): OpenedStreamEvent = open(wrap, keys.address, keys.readKey.conversationKey)
/** Like [open] but returns null instead of throwing on any validation failure. */
fun openOrNull(
wrap: Event,
stream: GroupKey,
): OpenedStreamEvent? = openOrNull(wrap, stream.publicKeyHex, stream.conversationKey)
/** Like the write-restricted [open] but returns null instead of throwing. */
fun openOrNull(
wrap: Event,
address: HexKey,
readConversationKey: ByteArray,
): OpenedStreamEvent? =
try {
open(wrap, stream)
open(wrap, address, readConversationKey)
} catch (_: Exception) {
null
}
/** Opens a Control Plane wrap with [keys] (split or legacy), or null on failure. */
fun openOrNull(
wrap: Event,
keys: ControlPlaneKeys,
): OpenedStreamEvent? = openOrNull(wrap, keys.address, keys.readKey.conversationKey)
private val EMPTY_TAGS = emptyArray<Array<String>>()
}
@@ -31,6 +31,7 @@ import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -55,12 +56,22 @@ class ConcordCommunityFactoryTest {
community.communityId,
)
// Two genesis wraps, both authored by the Control Plane address.
// Two genesis wraps, both authored by the Control Plane address — which on a fresh
// community is the control_root-derived signer, not the community_root (CORD-02 §5).
assertEquals(2, community.genesisWraps.size)
community.genesisWraps.forEach {
assertEquals(ConcordStreamEnvelope.KIND_WRAP, it.kind)
assertEquals(community.controlPlane.publicKeyHex, it.pubKey)
assertEquals(community.controlPlane.address, it.pubKey)
}
assertEquals(
ConcordKeyDerivation.controlSignerKey(community.controlRoot, community.communityId, community.rootEpoch).publicKeyHex,
community.controlPkHex,
)
// The plane is genuinely split: its address is NOT the legacy community_root derivation.
assertNotEquals(
ConcordKeyDerivation.controlPlaneKey(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex,
community.controlPkHex,
)
// Genesis wraps open with plaintext (20014) seals, authored by the owner.
val opened = community.genesisWraps.map { ConcordStreamEnvelope.open(it, community.controlPlane) }
@@ -35,6 +35,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConcordCommunityListTest {
@@ -45,14 +46,20 @@ class ConcordCommunityListTest {
id: String,
name: String,
epoch: Long = 0,
controlPk: String? = null,
controlRoot: String? = null,
inviteRef: String? = null,
) = ConcordCommunityListEntry(
id = id,
owner = "0f".repeat(32),
ownerSalt = "aa".repeat(32),
root = "bb".repeat(32),
rootEpoch = epoch,
controlPk = controlPk,
controlRoot = controlRoot,
relays = listOf("wss://relay.example"),
name = name,
inviteRef = inviteRef,
)
@Test
@@ -417,7 +424,7 @@ class ConcordCommunityListTest {
ownerSalt = decoded.ownerSalt,
root = decoded.root,
rootEpoch = decoded.rootEpoch,
heldRoots = decoded.heldRoots.map { HeldRoot(it.epoch, it.key, buildJsonObject { put("key", "STALE") }) },
heldRoots = decoded.heldRoots.map { HeldRoot(it.epoch, it.key, it.controlPk, it.controlRoot, buildJsonObject { put("key", "STALE") }) },
privateChannels = decoded.privateChannels.map { PrivateChannelKey(it.channelId, it.key, it.epoch, it.name, buildJsonObject { put("name", "STALE") }) },
relays = decoded.relays,
name = "Renamed",
@@ -486,4 +493,48 @@ class ConcordCommunityListTest {
assertEquals(2, merged.size)
assertEquals("New", merged.first { it.id == "11".repeat(32) }.name) // higher epoch wins
}
@Test
fun mergeAtTheSameEpochFillsMissingControlKeyMaterialFromEitherSide() {
// The second-device gap (CORD-02 §8): device A was promoted to staff (it adopted the
// control_root via a Grant's control_wrap), device B holds the invite anchor and the
// delivered control_pk. Both describe the same epoch, so a merge must end holding all
// of it — this is how the write secret reaches a staffer's own other devices.
val id = "11".repeat(32)
val promoted = listOf(entry(id, "Nostrichs", epoch = 2, controlPk = "cc".repeat(32), controlRoot = "dd".repeat(32)))
val joined = listOf(entry(id, "Nostrichs", epoch = 2, controlPk = "cc".repeat(32), inviteRef = "https://vector.chat/i/abc"))
val merged = ConcordCommunityList.merge(promoted, joined).single()
assertEquals("cc".repeat(32), merged.controlPk)
assertEquals("dd".repeat(32), merged.controlRoot, "the staff write key must reach the holder's other devices")
assertEquals("https://vector.chat/i/abc", merged.inviteRef, "the recovery anchor must survive the fill")
// Order-independent: the fill works whichever side holds the secret.
val reversed = ConcordCommunityList.merge(joined, promoted).single()
assertEquals("dd".repeat(32), reversed.controlRoot)
assertEquals("https://vector.chat/i/abc", reversed.inviteRef)
}
@Test
fun mergeNeverInheritsControlKeysAcrossEpochs() {
// A lower epoch's control_pk/control_root is stale for the winner's planes (CORD-06):
// the pair rolls at every Refounding, so carrying it forward would point the client at
// a dead address (pk) or derive a wrong one entirely (root). A winner without control
// material is a legacy rotation and must stay that way.
val id = "11".repeat(32)
val stale = listOf(entry(id, "Old", epoch = 1, controlPk = "cc".repeat(32), controlRoot = "dd".repeat(32), inviteRef = "https://vector.chat/i/abc"))
val rotated = listOf(entry(id, "New", epoch = 2))
val merged = ConcordCommunityList.merge(stale, rotated).single()
assertEquals(2, merged.rootEpoch)
assertNull(merged.controlPk, "a prior epoch's control_pk must not shadow the new epoch")
assertNull(merged.controlRoot, "a prior epoch's control_root must die with its epoch")
assertEquals("https://vector.chat/i/abc", merged.inviteRef) // the anchor, and only the anchor, survives
val reversed = ConcordCommunityList.merge(rotated, stale).single()
assertEquals(2, reversed.rootEpoch)
assertNull(reversed.controlPk)
assertNull(reversed.controlRoot)
assertEquals("https://vector.chat/i/abc", reversed.inviteRef)
}
}
@@ -0,0 +1,194 @@
/*
* 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.cord02Community
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The Control Plane's write restriction (CORD-01 Write-Restricted Streams, CORD-02 §2/§5).
*
* Every member holds the derived `control_pk` to subscribe, verify and read under the
* `community_root`-derived read key, but only the owner and staff hold the `control_root`
* the signer derives from so a member can read every edition and mint none.
*/
class ControlPlaneSplitTest {
private val owner = NostrSignerInternal(KeyPair())
private val now = 1_700_000_000L
private val communityRoot = ByteArray(32) { 0x11 }
private val controlRoot = ByteArray(32) { 0x22 }
private val communityId = ByteArray(32) { 0x33 }
private val epoch = 0L
private fun staffView() = ControlPlaneKeys.forStaff(communityRoot, communityId, epoch, controlRoot)
private fun memberView() = ControlPlaneKeys.forMember(communityRoot, communityId, epoch, staffView().address)
private suspend fun edition(createdAt: Long = now) =
ControlEditionBuilder.rumor(
authorPubKey = owner.pubKey,
entityKind = ControlEntityKind.METADATA,
entityId = communityId,
version = 0,
prevHash = null,
content = """{"name":"Nostrichs"}""",
createdAt = createdAt,
)
@Test
fun theSignerAndTheReadKeyAreDifferentKeysUnderDifferentLabels() {
val staff = staffView()
val legacy = ControlPlaneKeys.legacy(communityRoot, communityId, epoch)
// The address derives from the control_root, the read key from the community_root.
assertEquals(ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, epoch).publicKeyHex, staff.address)
assertNotEquals(staff.address, staff.readKey.publicKeyHex)
// The two schemes never collide: different labels, different addresses (CORD-02 §5).
assertNotEquals(legacy.address, staff.address)
// A legacy epoch is address, signer and read key at once — every member holds all three.
assertTrue(legacy.legacy)
assertTrue(legacy.canWrite)
assertEquals(legacy.address, legacy.readKey.publicKeyHex)
}
@Test
fun aMemberReadsEveryEditionButHoldsNoWriteKey() =
runTest {
val staff = staffView()
val member = memberView()
assertTrue(staff.canWrite)
assertFalse(member.canWrite, "a member must never hold the Control Plane write key")
// Same plane: same address to subscribe to, same conversation key to decrypt with.
assertEquals(staff.address, member.address)
assertContentEqualsHex(staff.readKey.conversationKey, member.readKey.conversationKey)
val wrap = ConcordStreamEnvelope.wrap(edition(), staff, owner, encrypted = false, createdAt = now)
assertEquals(staff.address, wrap.pubKey)
val opened = ConcordStreamEnvelope.openOrNull(wrap, member)
assertNotNull(opened, "a member must be able to read a staff-written edition")
assertEquals(owner.pubKey, opened.author)
assertNotNull(ControlEdition.fromRumor(opened.rumor))
}
@Test
fun aMemberCannotMintAWrapThatVerifiesAtThePlaneAddress() =
runTest {
val member = memberView()
// The only stream key a member holds is the community_root-derived read key. Signing
// with it produces a wrap at the WRONG address — the spam gate the split exists for.
val forged = ConcordStreamEnvelope.wrap(edition(), member.readKey, owner, encrypted = false, createdAt = now)
assertNotEquals(member.address, forged.pubKey)
assertNull(ConcordStreamEnvelope.openOrNull(forged, member), "a member-signed wrap must not open at the plane")
assertNull(ConcordStreamEnvelope.openOrNull(forged, staffView()))
}
@Test
fun aWrapFromAnUnrelatedKeyIsRefusedAtTheAddressCheck() =
runTest {
val staff = staffView()
// A spammer who somehow learned the read key still cannot mint at the address: the
// wrap's author must BE the address, and only control_root holders can produce it.
val strangerSecret = RandomInstance.bytes(32)
val stranger = ConcordKeyDerivation.groupKey("concord/whatever", strangerSecret, communityId, epoch)
val forged = ConcordStreamEnvelope.wrapSeal(ConcordStreamEnvelope.seal(edition(), staff.readKey, owner, encrypted = false), stranger, staff.readKey.conversationKey, createdAt = now)
assertNull(ConcordStreamEnvelope.openOrNull(forged, staff))
assertNull(ConcordStreamEnvelope.openOrNull(forged, memberView()))
}
@Test
fun wrappingWithoutTheWriteKeyIsRefusedRatherThanSilentlyMissigned() =
runTest {
val member = memberView()
val seal = ConcordStreamEnvelope.seal(edition(), member.readKey, owner, encrypted = false)
var threw = false
try {
ConcordStreamEnvelope.wrapSeal(seal, member)
} catch (_: IllegalArgumentException) {
threw = true
}
assertTrue(threw, "wrapping on a plane we cannot write to must fail loudly")
}
@Test
fun aLegacyEpochStaysReadableAfterTheSplitExists() =
runTest {
// A Community minted before the split keyed its plane by the member-held derivation.
// A client MUST retain that reading (CORD-02 §5) — the upgrade is the next Refounding.
val legacy = ControlPlaneKeys.legacy(communityRoot, communityId, epoch)
val wrap = ConcordStreamEnvelope.wrap(edition(), legacy, owner, encrypted = false, createdAt = now)
val opened = ConcordStreamEnvelope.openOrNull(wrap, legacy)
assertNotNull(opened)
assertEquals(owner.pubKey, opened.author)
// And it does not leak into the split scheme: the split plane refuses it.
assertNull(ConcordStreamEnvelope.openOrNull(wrap, staffView()))
}
@Test
fun heldSecretsSelectTheViewOfAnEpoch() {
val staffAddress = staffView().address
// Holding the secret: staff view, write key derived.
val asStaff = ControlPlaneKeys.of(communityRoot, communityId, epoch, controlPk = staffAddress, controlRoot = controlRoot.toHex())
assertTrue(asStaff.canWrite)
assertEquals(staffAddress, asStaff.address)
// Holding only the address: member view, read-only.
val asMember = ControlPlaneKeys.of(communityRoot, communityId, epoch, controlPk = staffAddress)
assertFalse(asMember.canWrite)
assertEquals(staffAddress, asMember.address)
// Holding neither: a legacy, pre-split epoch.
val asLegacy = ControlPlaneKeys.of(communityRoot, communityId, epoch)
assertTrue(asLegacy.legacy)
assertNotEquals(staffAddress, asLegacy.address)
}
private fun assertContentEqualsHex(
a: ByteArray,
b: ByteArray,
) = assertEquals(a.toHex(), b.toHex())
private fun ByteArray.toHex(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }
}
@@ -582,4 +582,37 @@ class AuthorityResolverTest {
assertFalse(r.isBanned(alice), "the part it does not outrank is dropped")
assertTrue(r.isBanned(carol), "the part it does outrank still lands")
}
@Test
fun staffIsTheOwnerPlusEveryControlWritingBitHolder() {
// Staff (CORD-04 §3) is the set that holds the control_root (CORD-02 §2): the owner
// always, plus every non-banned holder of a Control-writing bit. This set decides who
// receives the 136-byte staff blob at a Refounding (CORD-06 §1).
val pinRole = "33".repeat(32)
// PIN_MESSAGES alone (bit 11 = 2048) writes Control editions, so it is a staff bit.
val pinJson = """{"name":"Curator","position":6,"permissions":"2048"}"""
val r =
AuthorityResolver.resolve(
listOf(
role(adminRole, adminJson), // MANAGE_ROLES|KICK|BAN → staff via MANAGE_ROLES/BAN
role(modRole, modJson), // KICK only → Guestbook writer, NOT staff
role(pinRole, pinJson),
grant("31".repeat(32), alice, listOf(adminRole), granter = owner),
grant("32".repeat(32), bob, listOf(modRole), granter = owner),
grant("33".repeat(32), carol, listOf(adminRole), granter = owner),
grant("34".repeat(32), dave, listOf(pinRole), granter = owner),
banlist(carol), // a banned admin loses staff standing with everything else
),
owner,
)
assertTrue(r.isStaff(owner), "the owner is always staff")
assertTrue(r.isStaff(alice), "a Control-writing bit makes staff")
assertTrue(r.isStaff(dave), "PIN_MESSAGES lands as Control editions, so it is a staff bit")
assertFalse(r.isStaff(bob), "KICK writes to the Guestbook, never the Control Plane")
assertFalse(r.isStaff(carol), "a banned member is not staff")
assertFalse(r.isStaff("e5".repeat(32)), "a roleless member is not staff")
assertEquals(setOf(owner, alice, dave), r.staffMembers())
}
}
@@ -0,0 +1,164 @@
/*
* 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.cord04Roles
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
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.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The staff write-key delivery riding a Grant (CORD-04 §3): promotion and key delivery
* are one signed edition, opaque pairwise ciphertext to every other reader, and adopted
* only after the derive-check which fails closed.
*/
class ControlRootWrapTest {
private val granter = NostrSignerInternal(KeyPair())
private val member = NostrSignerInternal(KeyPair())
private val stranger = NostrSignerInternal(KeyPair())
private val communityId = ByteArray(32) { 0x33 }
private val controlRoot = ByteArray(32) { 0x22 }
private val epoch = 7L
private fun controlPkAt(
root: ByteArray = controlRoot,
at: Long = epoch,
) = ConcordKeyDerivation.controlSignerKey(root, communityId, at).publicKeyHex
@Test
fun theWirePlaintextIsFortyBytesEpochThenSecret() {
val plaintext = ControlRootWrap.encodePlaintext(epoch, controlRoot)
assertEquals(ControlRootWrap.SIZE, plaintext.size)
assertEquals(40, plaintext.size)
val decoded = ControlRootWrap.decodePlaintext(plaintext)
assertNotNull(decoded)
assertEquals(epoch, decoded.epoch)
assertContentEquals(controlRoot, decoded.controlRoot)
// Any other width is malformed — the rekey-blob discipline (CORD-06 §1).
assertNull(ControlRootWrap.decodePlaintext(ByteArray(39)))
assertNull(ControlRootWrap.decodePlaintext(ByteArray(41)))
}
@Test
fun thePromotedMemberOpensItAndNobodyElseCan() =
runTest {
val wrap = ControlRootWrap.build(granter, member.pubKey, epoch, controlRoot)
val opened = ControlRootWrap.openOrNull(wrap, member, granter.pubKey)
assertNotNull(opened)
assertEquals(epoch, opened.epoch)
assertContentEquals(controlRoot, opened.controlRoot)
// Every other reader of the plane sees opaque bytes.
assertNull(ControlRootWrap.openOrNull(wrap, stranger, granter.pubKey))
}
@Test
fun theGranterCanReopenItsOwnDeliveryBecauseTheKeyIsPairwise() =
runTest {
// One ECDH either side can compute, so a NIP-46 bunker account opens it with a
// single nip44_decrypt and a re-issuing staffer needs no stored copy.
val wrap = ControlRootWrap.build(granter, member.pubKey, epoch, controlRoot)
val opened = ControlRootWrap.openOrNull(wrap, granter, member.pubKey)
assertNotNull(opened)
assertContentEquals(controlRoot, opened.controlRoot)
}
@Test
fun adoptionRequiresTheSecretToDeriveToTheHeldAddress() {
assertTrue(ControlRootWrap.derivesTo(controlRoot, communityId, epoch, controlPkAt()))
// A garbage secret is attributable griefing, nothing worse: it is dropped, never adopted.
assertFalse(ControlRootWrap.derivesTo(ByteArray(32) { 0x77 }, communityId, epoch, controlPkAt()))
// The epoch binds too — a secret for another epoch derives elsewhere.
assertFalse(ControlRootWrap.derivesTo(controlRoot, communityId, epoch + 1, controlPkAt()))
// And so does the community: the same secret in another Community is another plane.
assertFalse(ControlRootWrap.derivesTo(controlRoot, ByteArray(32) { 0x44 }, epoch, controlPkAt()))
}
@Test
fun aWrapMintedForAPriorEpochFailsTheCheckRatherThanBeingAdopted() =
runTest {
// Compaction re-wraps a Grant head verbatim across Refoundings, so a folded head can
// carry a wrap minted for a prior epoch's key. Staleness is structural and harmless.
val staleWrap = ControlRootWrap.build(granter, member.pubKey, epoch, controlRoot)
val opened = ControlRootWrap.openOrNull(staleWrap, member, granter.pubKey)
assertNotNull(opened)
val currentEpoch = epoch + 1
val currentControlRoot = ByteArray(32) { 0x55 }
assertEquals(epoch, opened.epoch, "the epoch rides inside the ciphertext, not beside it")
assertFalse(
ControlRootWrap.derivesTo(opened.controlRoot, communityId, currentEpoch, controlPkAt(currentControlRoot, currentEpoch)),
"a stale wrap must fail closed at the current epoch",
)
}
@Test
fun aGrantCarriesTheWrapThroughTheWireShapeAndSurvivesARoundTrip() =
runTest {
val wrap = ControlRootWrap.build(granter, member.pubKey, epoch, controlRoot)
val grant = GrantEntity(member = member.pubKey, roleIds = listOf("ab".repeat(32)), controlWrap = wrap)
val json = ConcordJson.instance.encodeToString(GrantEntity.serializer(), grant)
assertTrue(json.contains("control_wrap"), "the wire field is snake_case (CORD-04 §2)")
val decoded = ConcordJson.decodeOrNull<GrantEntity>(json)
assertNotNull(decoded)
assertEquals(wrap, decoded.controlWrap)
// A plain grant carries none, and a reader must cope with its absence.
val plain = ConcordJson.decodeOrNull<GrantEntity>("""{"member":"${member.pubKey}","role_ids":[]}""")
assertNotNull(plain)
assertNull(plain.controlWrap)
}
@Test
fun theStaffBitsAreTheControlWritingPermissions() {
// The six bits whose actions land as Control editions (CORD-04 §3).
val staff = ConcordPermissions.STAFF_BITS
assertTrue(staff.has(ConcordPermissions.MANAGE_ROLES))
assertTrue(staff.has(ConcordPermissions.MANAGE_CHANNELS))
assertTrue(staff.has(ConcordPermissions.MANAGE_METADATA))
assertTrue(staff.has(ConcordPermissions.BAN))
assertTrue(staff.has(ConcordPermissions.CREATE_INVITE))
assertTrue(staff.has(ConcordPermissions.PIN_MESSAGES))
// KICK writes to the Guestbook and MANAGE_MESSAGES to Chat planes; neither needs the key.
assertFalse(staff.has(ConcordPermissions.KICK))
assertFalse(staff.has(ConcordPermissions.MANAGE_MESSAGES))
// PIN_MESSAGES claims the frozen bit 11 (CORD-04 §3 table).
assertEquals(11, ConcordPermissions.PIN_MESSAGES)
}
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -52,6 +52,9 @@ class ConcordInviteJoinFlowTest {
ownerSalt = community.ownerSalt.toHexKey(),
communityRoot = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
// Read access to the Control Plane, never write (CORD-05 §1): the joiner cannot
// derive this address, so the bundle is the only place it can come from.
controlPk = community.controlPkHex,
relays = listOf("wss://relay.example"),
name = "Nostrichs",
)
@@ -73,13 +76,18 @@ class ConcordInviteJoinFlowTest {
assertTrue(ConcordInviteBundle.validate(invite))
assertEquals(community.communityIdHex, invite.communityId)
// Reconstruct the root, derive the Control Plane, and read the genesis.
// Reconstruct the root and open the Control Plane as a plain member does: the
// delivered control_pk is the address to verify against, the derived read key
// decrypts (CORD-02 §5). No write key anywhere on this path.
assertNotNull(invite.controlPk)
val controlPlane =
ConcordKeyDerivation.controlPlaneKey(
ControlPlaneKeys.forMember(
invite.communityRoot.hexToByteArray(),
invite.communityId.hexToByteArray(),
invite.rootEpoch,
invite.controlPk,
)
assertFalse(controlPlane.canWrite, "an invite must never hand a joiner the write key")
val editions = community.genesisWraps.mapNotNull { ControlEdition.fromRumor(ConcordStreamEnvelope.open(it, controlPlane).rumor) }
val state = ConcordCommunityState.fold(editions, invite.owner)
assertEquals("Nostrichs", state.metadata?.name)
@@ -48,6 +48,9 @@ class ConcordRefoundingTest {
private val carol = NostrSignerInternal(KeyPair()) // removed
private val newRoot = ByteArray(32) { 0x5A }
/** The fresh staff write key minted beside [newRoot] at every Refounding (CORD-02 §2). */
private val newControlRoot = ByteArray(32) { 0x6B }
private val now = 1_700_000_000L
@Test
@@ -64,10 +67,12 @@ class ConcordRefoundingTest {
communityId = communityId,
priorRoot = priorRoot,
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = community.rootEpoch,
priorControlWraps = community.genesisWraps,
priorControlKey = priorControl,
priorControlKeys = priorControl,
recipientsXOnly = listOf(alice.pubKey, bob.pubKey),
staffXOnly = setOf(owner.pubKey),
createdAt = now,
)
@@ -77,9 +82,9 @@ class ConcordRefoundingTest {
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, build.newEpoch)
// Alice and Bob find the new root; Carol (no blob) does not.
val aliceRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, priorRoot, community.rootEpoch)
val bobRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, bob, priorRoot, community.rootEpoch)
val carolRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, carol, priorRoot, community.rootEpoch)
val aliceRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, communityId, priorRoot, community.rootEpoch)
val bobRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, bob, communityId, priorRoot, community.rootEpoch)
val carolRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, carol, communityId, priorRoot, community.rootEpoch)
assertNotNull(aliceRoot)
assertContentEquals(newRoot, aliceRoot.newRoot)
@@ -101,14 +106,16 @@ class ConcordRefoundingTest {
communityId = communityId,
priorRoot = community.communityRoot,
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = community.rootEpoch,
priorControlWraps = community.genesisWraps,
priorControlKey = community.controlPlane,
priorControlKeys = community.controlPlane,
recipientsXOnly = listOf(alice.pubKey),
staffXOnly = setOf(owner.pubKey),
createdAt = now,
)
val newControl = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, build.newEpoch)
val newControl = build.newControlKeys
// Re-open the compacted wraps under the NEW control key and fold: same authority + metadata.
val editions =
@@ -170,14 +177,16 @@ class ConcordRefoundingTest {
communityId = communityId,
priorRoot = community.communityRoot,
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = community.rootEpoch,
priorControlWraps = priorWraps,
priorControlKey = control,
priorControlKeys = control,
recipientsXOnly = listOf(alice.pubKey),
staffXOnly = setOf(owner.pubKey),
createdAt = now,
)
val newControl = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, build.newEpoch)
val newControl = build.newControlKeys
val editions =
build.controlWraps.mapNotNull { wrap ->
ConcordStreamEnvelope.openOrNull(wrap, newControl)?.let { ControlEdition.fromRumor(it.rumor) }
@@ -207,16 +216,18 @@ class ConcordRefoundingTest {
communityId = community.communityId,
priorRoot = community.communityRoot,
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = community.rootEpoch,
priorControlWraps = community.genesisWraps,
priorControlKey = community.controlPlane,
priorControlKeys = community.controlPlane,
recipientsXOnly = listOf(alice.pubKey),
staffXOnly = setOf(owner.pubKey),
createdAt = now,
)
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch)
// Alice claims a different prior root: prevcommit mismatch ⇒ rotation rejected.
val wrongRoot = ByteArray(32) { 0x11 }
assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, wrongRoot, community.rootEpoch))
assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, community.communityId, wrongRoot, community.rootEpoch))
}
}
@@ -0,0 +1,236 @@
/*
* 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.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The `control_root` rolling with the `community_root` at every Refounding
* (CORD-02 §2, CORD-06 §1/§3): base blobs carry the new pk to members and the new
* secret to staff, and the blob's width declares which form it is.
*/
class ControlRootRotationTest {
private val owner = NostrSignerInternal(KeyPair()) // rotator, and staff by definition
private val moderator = NostrSignerInternal(KeyPair()) // staff
private val member = NostrSignerInternal(KeyPair()) // plain member
private val removed = NostrSignerInternal(KeyPair())
private val newRoot = ByteArray(32) { 0x5A }
private val newControlRoot = ByteArray(32) { 0x6B }
private val now = 1_700_000_000L
@Test
fun theBlobWidthDeclaresItsForm() {
val scope = ByteArray(32) { 0x01 }
val key = ByteArray(32) { 0x02 }
val pk = ByteArray(32) { 0x03 }
val secret = ByteArray(32) { 0x04 }
assertEquals(RekeyPayload.SIZE_CHANNEL, RekeyPayload(scope, 1, key).encode().size)
assertEquals(RekeyPayload.SIZE_BASE_MEMBER, RekeyPayload(scope, 1, key, pk).encode().size)
assertEquals(RekeyPayload.SIZE_BASE_STAFF, RekeyPayload(scope, 1, key, pk, secret).encode().size)
assertEquals(72, RekeyPayload.SIZE_CHANNEL)
assertEquals(104, RekeyPayload.SIZE_BASE_MEMBER)
assertEquals(136, RekeyPayload.SIZE_BASE_STAFF)
// Round-trips keep exactly what each form carries, and nothing it doesn't.
val channel = RekeyPayload.decode(RekeyPayload(scope, 1, key).encode())
assertNotNull(channel)
assertNull(channel.newControlPk)
assertNull(channel.newControlRoot)
val memberBlob = RekeyPayload.decode(RekeyPayload(scope, 1, key, pk).encode())
assertNotNull(memberBlob)
assertContentEquals(pk, memberBlob.newControlPk)
assertNull(memberBlob.newControlRoot, "a member's blob must never carry the write key")
val staffBlob = RekeyPayload.decode(RekeyPayload(scope, 1, key, pk, secret).encode())
assertNotNull(staffBlob)
assertContentEquals(pk, staffBlob.newControlPk)
assertContentEquals(secret, staffBlob.newControlRoot)
// Any other width is malformed and the blob is dropped.
assertNull(RekeyPayload.decode(ByteArray(71)))
assertNull(RekeyPayload.decode(ByteArray(103)))
assertNull(RekeyPayload.decode(ByteArray(137)))
}
@Test
fun staffGetTheSecretMembersOnlyThePubkeyAndRemovedNothing() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Test", now)
val build =
ConcordRefounding.build(
rotatorSigner = owner,
communityId = community.communityId,
priorRoot = community.communityRoot,
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = community.rootEpoch,
priorControlWraps = community.genesisWraps,
priorControlKeys = community.controlPlane,
recipientsXOnly = listOf(owner.pubKey, moderator.pubKey, member.pubKey),
staffXOnly = setOf(owner.pubKey, moderator.pubKey),
createdAt = now,
)
val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch)
suspend fun received(who: NostrSignerInternal) = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekey, who, community.communityId, community.communityRoot, community.rootEpoch)
val expectedPk = ConcordKeyDerivation.controlSignerKey(newControlRoot, community.communityId, build.newEpoch).publicKey
val asModerator = received(moderator)
assertNotNull(asModerator)
assertContentEquals(newRoot, asModerator.newRoot)
assertContentEquals(expectedPk, asModerator.newControlPk)
assertContentEquals(newControlRoot, asModerator.newControlRoot, "staff must receive the new write key")
val asMember = received(member)
assertNotNull(asMember)
assertContentEquals(newRoot, asMember.newRoot)
assertContentEquals(expectedPk, asMember.newControlPk, "every member must receive the new address")
assertNull(asMember.newControlRoot, "a plain member must never receive the write key")
assertNull(received(removed), "a removed member receives no blob at all")
}
@Test
fun theRotatedPlaneIsWritableByStaffAndReadableByEveryMember() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Test", now, description = "A place")
val build =
ConcordRefounding.build(
rotatorSigner = owner,
communityId = community.communityId,
priorRoot = community.communityRoot,
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = community.rootEpoch,
priorControlWraps = community.genesisWraps,
priorControlKeys = community.controlPlane,
recipientsXOnly = listOf(owner.pubKey, member.pubKey),
staffXOnly = setOf(owner.pubKey),
createdAt = now,
)
// The rotator's own view writes; a member's view of the same epoch only reads.
assertTrue(build.newControlKeys.canWrite)
val memberView =
ControlPlaneKeys.forMember(newRoot, community.communityId, build.newEpoch, build.newControlKeys.address)
assertFalse(memberView.canWrite)
// Every compacted wrap sits at the new signer's address and opens for the member.
assertTrue(build.controlWraps.isNotEmpty())
build.controlWraps.forEach { assertEquals(build.newControlKeys.address, it.pubKey) }
val editions =
build.controlWraps.mapNotNull { wrap ->
ConcordStreamEnvelope.openOrNull(wrap, memberView)?.let { ControlEdition.fromRumor(it.rumor) }
}
val folded = ConcordCommunityState.fold(editions, owner.pubKey)
assertEquals("Test", folded.metadata?.name)
assertTrue(folded.channels.isNotEmpty())
}
@Test
fun aStaffBlobWhoseSecretDoesNotDeriveToItsPubkeyIsRefused() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Test", now)
val newEpoch = community.rootEpoch + 1
// A rotator that splits the plane from its own readers: the delivered secret derives
// to a DIFFERENT address than the one every member was handed (CORD-06 §1).
val mismatchedPk = ConcordKeyDerivation.controlSignerKey(ByteArray(32) { 0x7C }, community.communityId, newEpoch).publicKey
val blob =
ConcordRekey.blobForSigner(
rotatorSigner = owner,
recipientXOnly = moderator.pubKey.hexToByteArray(),
scopeId = ConcordRekey.ROOT_SCOPE,
newEpoch = newEpoch,
newKey = newRoot,
newControlPk = mismatchedPk,
newControlRoot = newControlRoot,
)
val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch)
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey()
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 0, 1)
val rumor =
RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now)
assertNull(
ConcordRefounding.findNewRoot(listOf(wrap), baseRekey, moderator, community.communityId, community.communityRoot, community.rootEpoch),
"a mismatched control pair must be refused rather than adopted",
)
}
@Test
fun aLegacySeventyTwoByteBaseBlobStillDeliversItsRoot() =
runTest {
// A pre-split rotation carries no control material; it is honored when reading old
// epochs (CORD-06 §3) and its acceptor keeps folding at the legacy address.
val community = ConcordCommunityFactory.create(owner, "Test", now)
val newEpoch = community.rootEpoch + 1
val blob =
ConcordRekey.blobForSigner(
rotatorSigner = owner,
recipientXOnly = member.pubKey.hexToByteArray(),
scopeId = ConcordRekey.ROOT_SCOPE,
newEpoch = newEpoch,
newKey = newRoot,
)
val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch)
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey()
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 0, 1)
val rumor =
RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now)
val got = ConcordRefounding.findNewRoot(listOf(wrap), baseRekey, member, community.communityId, community.communityRoot, community.rootEpoch)
assertNotNull(got)
assertContentEquals(newRoot, got.newRoot)
assertNull(got.newControlPk, "a legacy base blob announces a pre-split epoch")
assertNull(got.newControlRoot)
}
}