diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 966c01cedf..57d6f64e5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index be17eefef0..fba41f5e01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -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(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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 1b40e49820..d8c94f5ace 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -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(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)) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 7f52a986a8..618ae59581 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -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) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 7b41a02f8f..ff151176ef 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -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 = 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 16fc1b2757..3633b24dbe 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -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> { - val cp = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + ): Pair> { + 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index 379dc44b83..7ae731a877 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -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 = emptyList(), // Past access roots kept per epoch (CORD-06 Refounding rotates the root). Lets `read --epoch ` @@ -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 = "", ) /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index fa23a7ee33..d99679263f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -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, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, ): List = 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, - 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, + 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, - priorControlKey: GroupKey, + priorControlKeys: ControlPlaneKeys, recipientsXOnly: List, + staffXOnly: Set, 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, 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) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index 3e3e9b1de0..e5ba1e55d1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -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, @@ -138,7 +140,7 @@ object ConcordModeration { */ suspend fun defineChannel( actor: NostrSigner, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, channelId: ByteArray, channel: ChannelEntity, current: List, @@ -159,7 +161,7 @@ object ConcordModeration { */ suspend fun editMetadata( actor: NostrSigner, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, communityId: ByteArray, metadata: MetadataEntity, current: List, @@ -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, @@ -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, + current: List, + 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, + current: List, + 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, @@ -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, @@ -231,7 +287,7 @@ object ConcordModeration { private suspend fun setBanlist( actor: NostrSigner, - controlPlane: GroupKey, + controlPlane: ControlPlaneKeys, communityId: ByteArray, banned: Set, current: List, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 52b4436475..213121d0d4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -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 } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 49acfd9b1e..7670ff69c6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -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> = + private val historicalControlKeys: Map> = 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 = 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 = listOf(guestbookKey, nextBaseRekeyKey) /** The community's current Control Plane editions — the input a moderation edition chains onto. */ - fun controlEditions(): List = lock.withLock { editionsLocked(controlWraps.values.toList(), controlPlaneKey) } + fun controlEditions(): List = 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 = 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, - planeKey: GroupKey, + planeKeys: ControlPlaneKeys, ): List = 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 } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt index 86699776e7..cd0cbe7979 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt @@ -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() - /** 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) = 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) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index cb366ba6fa..09be414805 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt index 15c73f51d0..a5d8ff772d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt @@ -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()) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt index 77ed81f1ae..c5ae3af6a9 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt @@ -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, + 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(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)") + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt index d7e02a724e..0412057a24 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt @@ -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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt index 7f7882a14d..95e7721ff8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -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>() 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. diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt index 6f395de18e..25b14d7fd1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt @@ -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", ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt index 978b17130f..d3c1a50a75 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt @@ -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", ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt index 58b0c55253..10a771942e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt @@ -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() }) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt index 0419520238..81519fd100 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt @@ -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)) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt index f7d320c5ff..bf3dd5fb98 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt @@ -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, /** 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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index e77709ecdf..11ce25695e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -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 = emptyList(), val privateChannels: List = emptyList(), val relays: List = 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() 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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index bff6b54070..039a63e7ca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -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 = 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt index 13782071cf..4753cdf64c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index 71696ba8c9..33c3aba220 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -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 = emptyList(), + @SerialName("control_wrap") val controlWrap: String? = null, ) /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrap.kt new file mode 100644 index 0000000000..a08f16b4bf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrap.kt @@ -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() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt index 10bc1c2fbd..d652c70143 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -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 = emptyList(), val relays: List = emptyList(), val name: String = "", diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt index 1baf91cb5f..4f6e02d447 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt index 7a7bb34899..18df7e3c46 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt @@ -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, val rekeyWraps: List, -) +) { + /** 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, - priorControlKey: GroupKey, + priorControlKeys: ControlPlaneKeys, recipientsXOnly: List, + staffXOnly: Set, 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, - priorControlKey: GroupKey, - newControlKey: GroupKey, + priorControlKeys: ControlPlaneKeys, + newControlKeys: ControlPlaneKeys, ): List { // entity coordinate -> (head edition, its verified seal) val heads = HashMap>() 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, + staffXOnly: Set, newRoot: ByteArray, + newControlPk: ByteArray, + newControlRoot: ByteArray, newEpoch: Long, prevEpoch: Long, prevCommit: HexKey, createdAt: Long, ): List { 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, 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 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt index fed5b15eb9..84d9c1910d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt @@ -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, 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, + 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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt index e841183732..41856cbb8b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt @@ -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, + ) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt index b59827aadd..6ff9df015d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt index 0edb36ec9f..dcdd50c616 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt @@ -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" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ControlPlaneKeys.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ControlPlaneKeys.kt new file mode 100644 index 0000000000..6cf6c1d73f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ControlPlaneKeys.kt @@ -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) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt index acd8c2a007..7975d3aa37 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt @@ -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>() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt index 0c234713ca..e42b0e0c79 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt @@ -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) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt index b6ccaa8f81..4da831f09f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt @@ -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) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ControlPlaneSplitTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ControlPlaneSplitTest.kt new file mode 100644 index 0000000000..bbb60b54fc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ControlPlaneSplitTest.kt @@ -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') } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt index ec6ecae8c6..1d60fe7d91 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt @@ -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()) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrapTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrapTest.kt new file mode 100644 index 0000000000..5feb9ddeb2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlRootWrapTest.kt @@ -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(json) + assertNotNull(decoded) + assertEquals(wrap, decoded.controlWrap) + + // A plain grant carries none, and a reader must cope with its absence. + val plain = ConcordJson.decodeOrNull("""{"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) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt index 3d365679c8..5d6d9209ca 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt index fa919e9b14..06778c4c71 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt @@ -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)) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt new file mode 100644 index 0000000000..bcd5eec1e5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt @@ -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(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(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) + } +}