mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
+8
-2
@@ -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))
|
||||
|
||||
+7
-5
@@ -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 = "",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+80
-13
@@ -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)
|
||||
}
|
||||
|
||||
+67
-11
@@ -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>,
|
||||
|
||||
+7
-4
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+78
-14
@@ -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
|
||||
}
|
||||
|
||||
+32
-8
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+27
-5
@@ -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())
|
||||
|
||||
+82
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -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
|
||||
|
||||
|
||||
+5
-1
@@ -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.
|
||||
|
||||
+2
@@ -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",
|
||||
)
|
||||
|
||||
+26
-7
@@ -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",
|
||||
)
|
||||
|
||||
+18
-4
@@ -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() })
|
||||
}
|
||||
|
||||
+64
-3
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+19
-6
@@ -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,
|
||||
|
||||
+110
-11
@@ -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,
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
+18
-1
@@ -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
|
||||
|
||||
+9
@@ -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
|
||||
* granter↔member 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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+115
@@ -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 granter↔member 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()
|
||||
}
|
||||
+13
@@ -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 = "",
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+105
-34
@@ -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
|
||||
}
|
||||
|
||||
+34
-12
@@ -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 rotator↔recipient pairwise key). A
|
||||
* recipient computes their own locator, finds the matching blob, and decrypts the
|
||||
* new key; a member with no matching blob across all chunks of a complete rotation
|
||||
* pseudonym (public-input HKDF), and its `wrapped` field is the fixed-width
|
||||
* [RekeyPayload] (base64 → NIP-44 under the rotator↔recipient 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,
|
||||
|
||||
+53
-9
@@ -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 rotator↔recipient
|
||||
* pairwise key).
|
||||
* fixed-width [RekeyPayload], base64'd then NIP-44-encrypted under the
|
||||
* rotator↔recipient 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -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,
|
||||
|
||||
+11
-1
@@ -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"
|
||||
|
||||
|
||||
+127
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
-7
@@ -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>>()
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -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) }
|
||||
|
||||
+52
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+194
@@ -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') }
|
||||
}
|
||||
+33
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+164
@@ -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)
|
||||
}
|
||||
}
|
||||
+11
-3
@@ -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)
|
||||
|
||||
+21
-10
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+236
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user