mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
feat(concord): Private Channels verbs — create, privatise/publicise, vend on grant, rotate on revoke, receive (CORD-03/05/06)
- ConcordPrivateChannels (headless, shared with amy): create a Private Channel (key at channel epoch 0 + a bit-less access Role at the bottom of the roster, Armada createChannel), privatise a Public one (next channel epoch, floored at the highest rotation seen on the wire), publicise (flag only), the keep-set / authority for a rotation, the channel-rekey watch window (LOOKAHEAD epochs under the current and the prior root), receive + apply (race-safe: only from the epoch computed). - AccountConcordActions: createConcordChannel(private, accessRoleName), privatize/publicizeConcordChannel, rekeyConcordChannel (reserved key per rotation, every chunk confirmed before adopting), grant/revoke/ban reconcile channel access (Direct Invite limited to the gained channels; a rotation for the lost ones), the Refounding rotates every held Private Channel to its entitled kept set under the PRIOR root, and the revision tick drains channel rekeys and auto-adopts staff catch-ups (judgeCatchUp). - Catch-up hardening (audit): a Direct Invite catch-up only adds missing keys from a staff sender for live Private Channels, and is re-applied to the entry the List holds at write time (ConcordChannelListState.update) instead of a pre-suspend snapshot; every new verb writes the same way. - Session buffers channel-rekey wraps and AUTHs/subscribes their addresses; private history across a rotation reads the older keys the entry carries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
+24
-9
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
|
||||
@@ -212,24 +213,30 @@ object ConcordActions {
|
||||
|
||||
/**
|
||||
* The older Chat Planes of a channel this account can still read, beside [currentChannelPlane]:
|
||||
* - Public: its plane under every held prior root ([historicalChannelPlanes]), plus the
|
||||
* private-era plane when a channel key is held (a channel that was Private before);
|
||||
* - Private: none. Only the channel-key planes are its own; the root-derived plane is readable
|
||||
* by every member, so showing it would present public content as private (Armada
|
||||
* `channelsView`). With no priors kept per channel key, that leaves nothing.
|
||||
* - Public: its plane under every held prior root ([historicalChannelPlanes]), plus every
|
||||
* private-era plane a channel key is held for (a channel that was Private before);
|
||||
* - Private: the planes of the older channel keys the entry still carries (its `seed` and a
|
||||
* peer's `priors`, [ConcordChannelKeyring.historicalKeys]) — history across a channel rekey.
|
||||
* Never the root-derived plane: every member reads that one, so showing it would present
|
||||
* public content as private (Armada `channelsView`).
|
||||
*/
|
||||
fun historicalChannelPlanes(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
isPrivate: Boolean,
|
||||
): List<ChannelPlane> {
|
||||
if (isPrivate) return emptyList()
|
||||
val channelId = channelIdHex.hexToByteArray()
|
||||
val olderKeys =
|
||||
ConcordChannelKeyring.historicalKeys(entry, channelIdHex).map { old ->
|
||||
ChannelPlane(channelIdHex, old.epoch, ConcordChannelKeys.privateChannel(old.key.hexToByteArray(), channelId, old.epoch))
|
||||
}
|
||||
if (isPrivate) return olderKeys
|
||||
val rootEras = historicalChannelPlanes(entry.heldRoots, listOf(channelIdHex))
|
||||
val privateEra =
|
||||
heldPrivateChannelKey(entry, channelIdHex)?.let { held ->
|
||||
ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelIdHex.hexToByteArray(), held.epoch))
|
||||
ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelId, held.epoch))
|
||||
}
|
||||
return rootEras + listOfNotNull(privateEra)
|
||||
return rootEras + listOfNotNull(privateEra) + olderKeys
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -676,6 +683,7 @@ object ConcordActions {
|
||||
expiresAtMs: Long? = null,
|
||||
name: String = entry.name,
|
||||
icon: ImagePointer? = null,
|
||||
onlyChannelIds: Set<HexKey>? = null,
|
||||
): CommunityInvite =
|
||||
CommunityInvite(
|
||||
communityId = entry.id,
|
||||
@@ -684,7 +692,12 @@ object ConcordActions {
|
||||
communityRoot = entry.root,
|
||||
rootEpoch = entry.rootEpoch,
|
||||
controlPk = entry.controlPk,
|
||||
channels = ConcordInviteVend.toInviteChannels(ConcordInviteVend.vendableChannels(entry.privateChannels, authority, recipient)),
|
||||
channels =
|
||||
ConcordInviteVend.toInviteChannels(
|
||||
ConcordInviteVend
|
||||
.vendableChannels(entry.privateChannels, authority, recipient)
|
||||
.filter { onlyChannelIds == null || it.channelId.lowercase() in onlyChannelIds },
|
||||
),
|
||||
relays = entry.relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS),
|
||||
name = name.ifBlank { entry.name },
|
||||
icon = icon,
|
||||
@@ -705,6 +718,7 @@ object ConcordActions {
|
||||
sender: HexKey,
|
||||
recipient: HexKey,
|
||||
expiresAtMs: Long? = null,
|
||||
onlyChannelIds: Set<HexKey>? = null,
|
||||
): ConcordDirectInviteDraft {
|
||||
val to = recipient.lowercase()
|
||||
if (!HEX64.matches(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.INVALID_RECIPIENT)
|
||||
@@ -719,6 +733,7 @@ object ConcordActions {
|
||||
expiresAtMs = expiresAtMs,
|
||||
name = state.metadata?.name ?: entry.name,
|
||||
icon = state.metadata?.icon,
|
||||
onlyChannelIds = onlyChannelIds?.mapTo(HashSet()) { it.lowercase() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
* 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.amethyst.commons.actions
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
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.ConcordLimits
|
||||
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.cord04Roles.RoleScope
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRotation
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRotationAuthority
|
||||
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.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
/**
|
||||
* A Private Channel just minted or privatised (CORD-03 §2): its [channelIdHex], the independent
|
||||
* [key] to store in the Community List **before** [wraps] publish (a lost List write would orphan
|
||||
* the only copy), the access Role minted beside it ([roleIdHex]), and the Control editions to
|
||||
* publish in order — the Role first (an orphan Role is inert), then the channel edition.
|
||||
*/
|
||||
class PrivateChannelBuild(
|
||||
val channelIdHex: HexKey,
|
||||
val key: PrivateChannelKey,
|
||||
val roleIdHex: HexKey,
|
||||
val wraps: List<Event>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Private Channels end to end (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) as pure
|
||||
* builders and decisions, shared by the app and `amy`. The network and the Community List write
|
||||
* stay with the caller.
|
||||
*
|
||||
* Who may read a Private Channel is its Roles: a Role scoped `{kind:"channel", channel_id}` IS its
|
||||
* access list ([ConcordInviteVend.isEntitled]). Read access is enforced by key possession alone, so
|
||||
* this decides who a key is delivered TO (a Direct Invite on grant) and who a rotation keeps (a
|
||||
* channel rekey on revoke). Pinned to Armada's `channelAccess.ts`, `useCommunityActions`
|
||||
* (`createChannel`, `privatiseChannel`, `publiciseChannel`) and `useRekey` (`useChannelRekey`,
|
||||
* `useChannelRekeyWatch`).
|
||||
*/
|
||||
object ConcordPrivateChannels {
|
||||
/**
|
||||
* The position a new access Role takes (Armada `accessRolePosition`): the bottom of the roster,
|
||||
* never above what [actor] may mint (the owner mints from 1, anyone else strictly below their
|
||||
* own rank), so a grantee is never promoted by being let into a channel. Null when [actor] holds
|
||||
* no rank to mint from.
|
||||
*/
|
||||
fun accessRolePosition(
|
||||
authority: AuthorityResolver?,
|
||||
actor: HexKey,
|
||||
owner: HexKey,
|
||||
): Long? {
|
||||
val ceiling =
|
||||
if (actor.equals(owner, ignoreCase = true)) {
|
||||
1L
|
||||
} else {
|
||||
(authority?.rank(actor) ?: return null) + 1
|
||||
}
|
||||
val lowest =
|
||||
authority
|
||||
?.roles()
|
||||
?.values
|
||||
?.filterNot { it.deleted }
|
||||
?.maxOfOrNull { it.position } ?: 0L
|
||||
return maxOf(ceiling, lowest + 1)
|
||||
}
|
||||
|
||||
/** [name] cut to the protocol's 64-byte cap on a character boundary (Armada slices the same way). */
|
||||
private fun fitName(name: String): String {
|
||||
var out = name
|
||||
while (!ConcordLimits.nameFits(out)) out = out.dropLast(1)
|
||||
return out
|
||||
}
|
||||
|
||||
/** The bit-less access Role scoped to [channelIdHex] (CORD-04 §2): read access is the key, never a bit. */
|
||||
fun accessRole(
|
||||
name: String,
|
||||
channelIdHex: HexKey,
|
||||
position: Long,
|
||||
): RoleEntity =
|
||||
RoleEntity(
|
||||
name = fitName(name),
|
||||
position = position,
|
||||
permissions = "0",
|
||||
scope = RoleScope(kind = "channel", channelId = channelIdHex.lowercase()),
|
||||
)
|
||||
|
||||
/**
|
||||
* A new channel (CORD-03 §2): a random `channel_id`, and for a Private one an independent key
|
||||
* at channel epoch 0 plus its access Role (Armada `createChannel`: a born-private channel is
|
||||
* epoch 0; the first *privatisation* of a public channel is epoch 1). Returns null when the
|
||||
* name is invalid or the actor has no rank to mint the Role from.
|
||||
*/
|
||||
suspend fun create(
|
||||
actor: NostrSigner,
|
||||
cp: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
name: String,
|
||||
accessRoleName: String?,
|
||||
current: List<ControlEdition>,
|
||||
authority: AuthorityResolver?,
|
||||
owner: HexKey,
|
||||
createdAt: Long,
|
||||
): PrivateChannelBuild? {
|
||||
val channel = ChannelEntity(name = name.trim(), private = true)
|
||||
if (!channel.hasValidName()) return null
|
||||
val position = accessRolePosition(authority, actor.pubKey, owner) ?: return null
|
||||
val channelId = RandomInstance.bytes(32)
|
||||
val channelIdHex = channelId.toHexKey()
|
||||
val roleId = RandomInstance.bytes(32)
|
||||
val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: channel.name, channelIdHex, position)
|
||||
val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = owner)
|
||||
val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelId, channel, current, createdAt, owner = owner)
|
||||
return PrivateChannelBuild(
|
||||
channelIdHex = channelIdHex,
|
||||
key = PrivateChannelKey(channelIdHex, ConcordChannelRekey.mintKey().toHexKey(), 0, channel.name),
|
||||
roleIdHex = roleId.toHexKey(),
|
||||
wraps = listOf(roleWrap, channelWrap),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh independent key at
|
||||
* the NEXT channel epoch ([ConcordChannelKeyring.nextChannelEpoch], floored at [observedFloor],
|
||||
* the highest channel rotation seen on the wire), a new access Role, and the channel edition
|
||||
* flipping `private` on — every other field of [standing] carried through. Protects the future
|
||||
* only: the public era stays readable to every member. Null when the actor can't mint the Role.
|
||||
*/
|
||||
suspend fun privatize(
|
||||
actor: NostrSigner,
|
||||
cp: ControlPlaneKeys,
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
standing: ChannelEntity,
|
||||
accessRoleName: String?,
|
||||
current: List<ControlEdition>,
|
||||
authority: AuthorityResolver?,
|
||||
createdAt: Long,
|
||||
observedFloor: Long = 0,
|
||||
): PrivateChannelBuild? {
|
||||
if (standing.private || standing.deleted) return null
|
||||
val position = accessRolePosition(authority, actor.pubKey, entry.owner) ?: return null
|
||||
val communityId = entry.id.hexToByteArray()
|
||||
val roleId = RandomInstance.bytes(32)
|
||||
val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: standing.name, channelIdHex, position)
|
||||
val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = entry.owner)
|
||||
val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = true), current, createdAt, owner = entry.owner)
|
||||
val epoch = ConcordChannelKeyring.nextChannelEpoch(entry, channelIdHex, observedFloor)
|
||||
return PrivateChannelBuild(
|
||||
channelIdHex = channelIdHex.lowercase(),
|
||||
key = PrivateChannelKey(channelIdHex.lowercase(), ConcordChannelRekey.mintKey().toHexKey(), epoch, standing.name),
|
||||
roleIdHex = roleId.toHexKey(),
|
||||
wraps = listOf(roleWrap, channelWrap),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The
|
||||
* channel derives from the `community_root` from here on; the held key stays in the List so its
|
||||
* holders keep reading the private era, which a later joiner never can. Null when it is not
|
||||
* private.
|
||||
*/
|
||||
suspend fun publicize(
|
||||
actor: NostrSigner,
|
||||
cp: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
channelIdHex: HexKey,
|
||||
standing: ChannelEntity,
|
||||
current: List<ControlEdition>,
|
||||
owner: HexKey,
|
||||
createdAt: Long,
|
||||
): Event? {
|
||||
if (!standing.private || standing.deleted) return null
|
||||
return ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = false), current, createdAt, owner = owner)
|
||||
}
|
||||
|
||||
// ---- channel rotations (CORD-06 §1-2) -------------------------------------
|
||||
|
||||
/**
|
||||
* Whether [actor] may launch a single-channel Rekey cutting [removed] (CORD-06 §3 Authority):
|
||||
* `MANAGE_CHANNELS` (or `BAN`, a Refounding's) and strictly outranking every removed target.
|
||||
* The owner may always; the owner is never a valid target.
|
||||
*/
|
||||
fun canRotate(
|
||||
authority: AuthorityResolver,
|
||||
actor: HexKey,
|
||||
removed: Collection<HexKey>,
|
||||
bit: Int = ConcordPermissions.MANAGE_CHANNELS,
|
||||
): Boolean {
|
||||
if (authority.isOwner(actor)) return true
|
||||
if (!authority.hasPermission(actor, bit)) return false
|
||||
return removed.all { it.equals(actor, ignoreCase = true) || authority.canActOn(actor, it, bit) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The members a rotation of [channelIdHex] keeps (Armada `handleRotateChannelKey`): exactly
|
||||
* those entitled today ([ConcordInviteVend.entitledMembers]) plus the [rotator], who must keep
|
||||
* every key or nobody could rotate the channel next time.
|
||||
*/
|
||||
fun keepSet(
|
||||
authority: AuthorityResolver,
|
||||
channelIdHex: HexKey,
|
||||
rotator: HexKey,
|
||||
): Set<HexKey> = ConcordInviteVend.entitledMembers(authority, channelIdHex) + rotator.lowercase()
|
||||
|
||||
/**
|
||||
* The wraps of one rotation of [held] to [newKey] for [keep], sealed under [sealingRoot] (the
|
||||
* current root for a single-channel Rekey, the PRIOR root inside a Refounding — CORD-06 §3), and
|
||||
* citing [authority] on every chunk.
|
||||
*/
|
||||
suspend fun buildRotation(
|
||||
rotator: NostrSigner,
|
||||
sealingRoot: ByteArray,
|
||||
held: PrivateChannelKey,
|
||||
newKey: ByteArray,
|
||||
keep: Collection<HexKey>,
|
||||
createdAt: Long,
|
||||
authority: AuthorityCitation?,
|
||||
): List<Event> =
|
||||
ConcordChannelRekey.build(
|
||||
rotatorSigner = rotator,
|
||||
sealingRoot = sealingRoot,
|
||||
channelId = held.channelId.hexToByteArray(),
|
||||
heldKey = held.key.hexToByteArray(),
|
||||
heldEpoch = held.epoch,
|
||||
newKey = newKey,
|
||||
recipients = keep + rotator.pubKey,
|
||||
createdAt = createdAt,
|
||||
authority = authority,
|
||||
)
|
||||
|
||||
/** The roots a member watches channel rekeys under: the current one and the canonical prior one (CORD-06 §3). */
|
||||
fun watchRoots(entry: ConcordCommunityListEntry): List<ByteArray> {
|
||||
val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).filter { it.epoch == entry.rootEpoch - 1 }
|
||||
return (listOf(entry.root) + prior.map { it.key }).distinct().map { it.hexToByteArray() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every channel-rekey address this entry should watch (CORD-06 §2): per held Private Channel,
|
||||
* the next [ConcordChannelRekey.LOOKAHEAD] channel epochs past the held one, under each of
|
||||
* [watchRoots]. Address hex → key.
|
||||
*/
|
||||
fun watchKeys(entry: ConcordCommunityListEntry): Map<HexKey, GroupKey> {
|
||||
val out = LinkedHashMap<HexKey, GroupKey>()
|
||||
val roots = watchRoots(entry)
|
||||
for (held in entry.privateChannels) {
|
||||
if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue
|
||||
val channelId = held.channelId.hexToByteArray()
|
||||
for (root in roots) {
|
||||
for (ahead in 1..ConcordChannelRekey.LOOKAHEAD) {
|
||||
val key = ConcordChannelRekey.address(root, channelId, held.epoch + ahead)
|
||||
out[key.publicKeyHex] = key
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a received channel rotation's Rotator may be honored (CORD-06 §3 Authority): the owner,
|
||||
* or a non-banned holder of `MANAGE_CHANNELS` (a single-channel Rekey) or `BAN` (a Refounding's
|
||||
* channel rekeys), whose `vac` cites a Grant our fold has synced. Key possession is never
|
||||
* authority.
|
||||
*/
|
||||
fun isHonoredRotation(
|
||||
entry: ConcordCommunityListEntry,
|
||||
editions: Collection<ControlEdition>,
|
||||
authority: AuthorityResolver,
|
||||
rotation: ChannelRotation,
|
||||
): Boolean {
|
||||
val rotator = rotation.rotator
|
||||
if (!authority.isOwner(rotator)) {
|
||||
if (authority.isBanned(rotator)) return false
|
||||
if (!authority.hasPermission(rotator, ConcordPermissions.MANAGE_CHANNELS) && !authority.hasPermission(rotator, ConcordPermissions.BAN)) return false
|
||||
}
|
||||
val heads = ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner)
|
||||
return ConcordRotationAuthority.citationSatisfied(entry.id, rotator, entry.owner, rotation.authority, heads)
|
||||
}
|
||||
|
||||
/** Whether [rotator] strictly outranks [me] — only such a Rotator's omission is a cut (CORD-06 §3). */
|
||||
fun outranks(
|
||||
authority: AuthorityResolver,
|
||||
rotator: HexKey,
|
||||
me: HexKey,
|
||||
): Boolean {
|
||||
if (authority.isOwner(me)) return false
|
||||
val theirs = authority.rank(rotator) ?: return false
|
||||
val mine = authority.rank(me) ?: Long.MAX_VALUE
|
||||
return theirs < mine
|
||||
}
|
||||
|
||||
/**
|
||||
* What the buffered channel-rekey [wraps] mean for each Private Channel [entry] holds a key for
|
||||
* (CORD-06 §2), per channel id: an adoption moves that channel's key forward, a cut drops it and
|
||||
* records `channel_cuts`. Channels with nothing to do are omitted. The caller applies the result
|
||||
* inside its List write ([applyOutcome]).
|
||||
*/
|
||||
suspend fun receive(
|
||||
entry: ConcordCommunityListEntry,
|
||||
wraps: Collection<Event>,
|
||||
editions: Collection<ControlEdition>,
|
||||
authority: AuthorityResolver,
|
||||
recipient: NostrSigner,
|
||||
): Map<HexKey, ChannelRekeyOutcome> {
|
||||
if (wraps.isEmpty()) return emptyMap()
|
||||
val keys = watchKeys(entry)
|
||||
val me = recipient.pubKey
|
||||
val joinedAtSecs = entry.addedAt / 1000
|
||||
val out = LinkedHashMap<HexKey, ChannelRekeyOutcome>()
|
||||
for (held in entry.privateChannels) {
|
||||
if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue
|
||||
val id = held.channelId.lowercase()
|
||||
val rotations = ConcordChannelRekey.rotations(wraps, keys, id)
|
||||
if (rotations.isEmpty()) continue
|
||||
val outcome =
|
||||
ConcordChannelRekey.walk(
|
||||
rotations = rotations,
|
||||
channelIdHex = id,
|
||||
heldKey = held.key.hexToByteArray(),
|
||||
heldEpoch = held.epoch,
|
||||
recipientSigner = recipient,
|
||||
joinedAtSecs = joinedAtSecs,
|
||||
honored = { isHonoredRotation(entry, editions, authority, it) },
|
||||
outranksMe = { outranks(authority, it, me) },
|
||||
)
|
||||
if (outcome !is ChannelRekeyOutcome.None) out[id] = outcome
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* [current] with [outcomes] applied — only where the channel is still at the epoch the outcome
|
||||
* was computed from ([fromEpochs]), so a write racing another adoption never rolls a key back —
|
||||
* or null when nothing changed.
|
||||
*/
|
||||
fun applyOutcome(
|
||||
current: ConcordCommunityListEntry,
|
||||
outcomes: Map<HexKey, ChannelRekeyOutcome>,
|
||||
fromEpochs: Map<HexKey, Long>,
|
||||
): ConcordCommunityListEntry? {
|
||||
var next = current
|
||||
for ((id, outcome) in outcomes) {
|
||||
val held = ConcordChannelKeyring.heldKey(next, id) ?: continue
|
||||
if (held.epoch != fromEpochs[id]) continue
|
||||
next =
|
||||
when (outcome) {
|
||||
is ChannelRekeyOutcome.Adopted -> ConcordChannelKeyring.withRotatedKey(next, id, outcome.key.toHexKey(), outcome.epoch) ?: next
|
||||
is ChannelRekeyOutcome.Removed -> ConcordChannelKeyring.withoutChannel(next, id, outcome.epoch)
|
||||
ChannelRekeyOutcome.None -> next
|
||||
}
|
||||
}
|
||||
return if (next === current) null else next
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -96,7 +96,8 @@ object ConcordSubscriptionPlanner {
|
||||
* The off-channel planes every joined community subscribes to upfront (known
|
||||
* from the entry alone): the Guestbook Plane (membership motions) and the
|
||||
* next-epoch base-rekey address (so an inbound Refounding is received live,
|
||||
* CORD-06), and the dissolution tombstone address (CORD-02 §9). All are kind-1059
|
||||
* CORD-06), the dissolution tombstone address (CORD-02 §9), and every held Private Channel's
|
||||
* next channel-rekey addresses (CORD-06 §2). All are kind-1059
|
||||
* wraps authored by their derived stream address.
|
||||
*/
|
||||
fun auxiliaryPlaneSubs(entries: List<ConcordCommunityListEntry>): List<ConcordPlaneSub> =
|
||||
@@ -114,7 +115,10 @@ object ConcordSubscriptionPlanner {
|
||||
ConcordPlaneSub(channelId = null, pubKeyHex = dissolved.publicKeyHex, relays = relays),
|
||||
// The current epoch's own rekey address, so a racing sibling can heal us (CORD-06 §3).
|
||||
sibling?.let { ConcordPlaneSub(channelId = null, pubKeyHex = it.publicKeyHex, relays = relays) },
|
||||
)
|
||||
) +
|
||||
// Each held Private Channel's next channel-rekey addresses (CORD-06 §2), so a rotation
|
||||
// that moves the key forward — or cuts us — is received live.
|
||||
ConcordPrivateChannels.watchKeys(e).keys.map { ConcordPlaneSub(channelId = null, pubKeyHex = it, relays = relays) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4185,6 +4185,11 @@ class Account(
|
||||
// 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 Private Channel rotation lands on its channel-rekey address (CORD-06 §2): adopt the new
|
||||
// key, or drop the channel when it cut us.
|
||||
runCatching { concord.drainConcordChannelRekeys() }.onFailure { Log.w("Concord", "channel rekey drain failed", it) }
|
||||
// A Grant folding late turns a parked catch-up invite into one we adopt without a click.
|
||||
runCatching { concord.drainConcordCatchUps() }.onFailure { Log.w("Concord", "catch-up 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) }
|
||||
|
||||
+357
-5
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordPinContext
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays
|
||||
@@ -53,8 +54,10 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordListTooLargeExcep
|
||||
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.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch
|
||||
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.ConcordLimits
|
||||
@@ -68,8 +71,11 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
|
||||
@@ -131,6 +137,9 @@ private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
|
||||
*/
|
||||
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
|
||||
|
||||
// How many channel epochs a privatisation probes for earlier rotations (Armada MAX_PROBED_CHANNEL_EPOCH).
|
||||
private const val MAX_PROBED_CHANNEL_EPOCH = 32L
|
||||
|
||||
/** A lowercase 32-byte hex key (the Guestbook `invite` tag's creator). */
|
||||
private val HEX64 = Regex("^[0-9a-f]{64}$")
|
||||
|
||||
@@ -191,6 +200,21 @@ class AccountConcordActions(
|
||||
/** Adds or replaces [entry] in the Community List; false when it could not be written. */
|
||||
private suspend fun persistConcordEntry(entry: ConcordCommunityListEntry): Boolean = writeConcordList { it.follow(entry) }
|
||||
|
||||
/**
|
||||
* Rewrites the held entry for [communityId] through [transform], applied to the entry **as the
|
||||
* List holds it inside the write** ([ConcordChannelListState.update]) — never to a snapshot read
|
||||
* before a suspension, which a concurrent rekey or import could have moved on. True only when
|
||||
* [transform] produced a change and it was written.
|
||||
*/
|
||||
private suspend fun updateConcordEntry(
|
||||
communityId: String,
|
||||
transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?,
|
||||
): Boolean {
|
||||
var changed = false
|
||||
val ok = writeConcordList { list -> list.update(communityId) { cur -> transform(cur)?.also { changed = true } } }
|
||||
return ok && changed
|
||||
}
|
||||
|
||||
/** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */
|
||||
private suspend fun announceConcordGuestbookJoin(
|
||||
entry: ConcordCommunityListEntry,
|
||||
@@ -814,9 +838,42 @@ class AccountConcordActions(
|
||||
val filter = ConcordActions.directInvitesFilter(account.signer.pubKey, directInviteInbox.since())
|
||||
val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(filter) })
|
||||
wraps.distinctBy { it.id }.forEach { directInviteInbox.offer(it) }
|
||||
drainConcordCatchUps()
|
||||
return (directInviteInbox.pending.value.keys - before).size
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopts, without a click, every parked catch-up the held fold says is exactly the delivery a
|
||||
* Grant prescribes (Armada `judgeCatchUp`, [ConcordInviteVend.judgeCatchUp]): a staff sender, a
|
||||
* recipient who isn't banned, and only live Private Channels our Roles entitle us to. Consent
|
||||
* came from the Grant. Anything else waits for a manual Accept. Runs after an inbox sweep and on
|
||||
* the revision tick (a Grant folding late turns a waiting catch-up adoptable).
|
||||
*/
|
||||
internal suspend fun drainConcordCatchUps() {
|
||||
if (!account.isWriteable()) return
|
||||
val me = account.signer.pubKey
|
||||
val now = TimeUtils.nowMillis()
|
||||
for (opened in directInviteInbox.pending.value.values) {
|
||||
if (opened.isExpired(now)) continue
|
||||
val held =
|
||||
account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id.equals(opened.invite.communityId, ignoreCase = true) } ?: continue
|
||||
val state =
|
||||
account.concordSessions
|
||||
.sessionFor(held.id)
|
||||
?.state
|
||||
?.value ?: continue
|
||||
if (state.dissolved) continue
|
||||
val verdict = ConcordInviteVend.judgeCatchUp(state.authority, state.privateChannelIds, me, opened.sender, opened.invite, held)
|
||||
if (verdict != ConcordInviteVend.CatchUpVerdict.ADOPT) continue
|
||||
val ids = ConcordInviteVend.catchUpChannelIds(held, opened.invite)
|
||||
if (updateConcordEntry(held.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, opened.invite, ids) }) {
|
||||
Log.i("Concord") { "Adopted a granted Private Channel key for ${held.id} from ${opened.sender}" }
|
||||
directInviteInbox.resolve(opened.wrapId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipient's giftwrap inbox (CORD-05 §6): their kind-10050 DM relays, else NIP-65 read
|
||||
* relays — from the cache when we have their lists, fetched otherwise — else the stock set.
|
||||
@@ -852,6 +909,7 @@ class AccountConcordActions(
|
||||
communityId: String,
|
||||
recipientPubKey: HexKey,
|
||||
expiresAtMs: Long? = null,
|
||||
onlyChannelIds: Set<HexKey>? = null,
|
||||
): ConcordDirectInviteSendResult {
|
||||
if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE
|
||||
val recipient = recipientPubKey.lowercase()
|
||||
@@ -865,7 +923,7 @@ class AccountConcordActions(
|
||||
?.state
|
||||
?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED
|
||||
val invite =
|
||||
when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs)) {
|
||||
when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs, onlyChannelIds)) {
|
||||
is ConcordDirectInviteDraft.Refused -> return draft.reason
|
||||
is ConcordDirectInviteDraft.Ready -> draft.invite
|
||||
}
|
||||
@@ -907,9 +965,12 @@ class AccountConcordActions(
|
||||
// No folded roster yet: whether it bans us is unknown, so the invite waits.
|
||||
DirectInviteAcceptPlan.RosterNotLoaded -> ConcordInviteResult.NotReachable
|
||||
DirectInviteAcceptPlan.NothingNew -> ConcordInviteResult.Joined(bundle.communityId)
|
||||
// Keys only, on the held base: no second Guestbook Join.
|
||||
is DirectInviteAcceptPlan.CatchUp ->
|
||||
if (persistConcordEntry(plan.entry)) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable
|
||||
// Keys only, on the held base: no second Guestbook Join. Re-applied to the entry the
|
||||
// List holds at write time, so a root imported meanwhile is never written back over.
|
||||
is DirectInviteAcceptPlan.CatchUp -> {
|
||||
val ok = writeConcordList { list -> list.update(plan.entry.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, bundle, plan.channelIds) } }
|
||||
if (ok) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable
|
||||
}
|
||||
DirectInviteAcceptPlan.Join ->
|
||||
joinValidatedConcordInvite(
|
||||
bundle = bundle,
|
||||
@@ -1307,6 +1368,7 @@ class AccountConcordActions(
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
val before = session.state.value?.authority
|
||||
// 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.
|
||||
@@ -1324,6 +1386,8 @@ class AccountConcordActions(
|
||||
epoch = session.entry.rootEpoch,
|
||||
)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
// Role-gated channel keys follow the Grant: vend what it opened, rotate what it closed.
|
||||
reconcileConcordChannelAccess(communityId, before)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1429,8 +1493,10 @@ class AccountConcordActions(
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
val before = session.state.value?.authority
|
||||
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
reconcileConcordChannelAccess(communityId, before)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1487,14 +1553,20 @@ class AccountConcordActions(
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
|
||||
val before = session.state.value?.authority
|
||||
val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
// Judged on the fold that now carries the ban (publishConcordWrap ingests it first).
|
||||
val state = session.state.value
|
||||
if (state != null && state.banRequiresRefounding(listOf(member))) {
|
||||
// The Refounding rotates every held Private Channel to its entitled set (CORD-06 §3).
|
||||
if (!refoundConcordCommunity(communityId, setOf(member))) {
|
||||
Log.w("Concord") { "Banned $member from the Private community $communityId, but its Refounding did not complete" }
|
||||
}
|
||||
} else {
|
||||
// A Public ban keeps the base, so the Private Channels the target could read are cut by
|
||||
// their own rekeys (CORD-04 §6: "a Private-Channel rekey for a channel-scoped cut").
|
||||
reconcileConcordChannelAccess(communityId, before)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1850,9 +1922,14 @@ class AccountConcordActions(
|
||||
}
|
||||
if (!compactionLanded) Log.w("Concord") { "Refounding ${entry.id}: some compacted Control Plane heads were not accepted at epoch ${build.newEpoch}" }
|
||||
|
||||
// 5b. Rotate every held Private Channel (CORD-06 §3), each to its OWN entitled set among the
|
||||
// kept members, sealed under the PRIOR root so a base-fork loser can still open it. A
|
||||
// channel that fails to land keeps its key (and is logged): resumable, not atomic.
|
||||
val rotatedEntry = rotatePrivateChannelsForRefounding(entry, recipients.toSet(), priorRoot, citation)
|
||||
|
||||
// 6. 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.
|
||||
val adopted = adoptConcordRoot(entry, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), keys.newControlRoot)
|
||||
val adopted = adoptConcordRoot(rotatedEntry, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), keys.newControlRoot)
|
||||
pendingConcordRefoundings.remove(entry.id)
|
||||
|
||||
// 7. Move every link we minted to the new epoch. Without this the Refounding orphans them,
|
||||
@@ -1864,6 +1941,40 @@ class AccountConcordActions(
|
||||
return compactionLanded
|
||||
}
|
||||
|
||||
/**
|
||||
* The Refounding's channel duty (CORD-06 §3): every held key of a live Private Channel is
|
||||
* rotated to the members still entitled to it ∩ [kept], plus ourselves, sealed under
|
||||
* [priorRoot]. Returns [entry] with each rotated channel moved to its new key (the caller adopts
|
||||
* the new root from it); a channel whose rotation didn't land keeps its old key.
|
||||
*/
|
||||
private suspend fun rotatePrivateChannelsForRefounding(
|
||||
entry: ConcordCommunityListEntry,
|
||||
kept: Set<HexKey>,
|
||||
priorRoot: ByteArray,
|
||||
citation: AuthorityCitation?,
|
||||
): ConcordCommunityListEntry {
|
||||
val session = account.concordSessions.sessionFor(entry.id) ?: return entry
|
||||
val state = session.state.value ?: return entry
|
||||
val me = account.signer.pubKey.lowercase()
|
||||
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
val keptLower = kept.mapTo(HashSet()) { it.lowercase() }
|
||||
var next = entry
|
||||
for (held in entry.privateChannels) {
|
||||
val id = held.channelId.lowercase()
|
||||
if (id !in state.privateChannelIds || ConcordChannelKeyring.heldKey(entry, id) == null) continue
|
||||
val keep = ConcordPrivateChannels.keepSet(state.authority, id, me).filterTo(HashSet()) { it in keptLower || it == me }
|
||||
val newKey = ConcordChannelRekey.mintKey()
|
||||
val wraps = ConcordPrivateChannels.buildRotation(account.signer, priorRoot, held, newKey, keep, TimeUtils.now(), citation)
|
||||
val landed = wraps.all { runCatching { account.client.publishAndConfirm(it, publishTo) }.getOrDefault(false) }
|
||||
if (!landed) {
|
||||
Log.w("Concord") { "Refounding ${entry.id}: the rekey of private channel $id did not land; it keeps its key" }
|
||||
continue
|
||||
}
|
||||
next = ConcordChannelKeyring.withRotatedKey(next, id, newKey.toHexKey(), held.epoch + 1) ?: next
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// Keys reserved for a Refounding in flight, per community (CORD-06 §3): a retry of the same
|
||||
// rotation reuses them. Process-local — a restart mid-rotation mints afresh, which is why the
|
||||
// rekey chunks are all confirmed before anything is adopted.
|
||||
@@ -2266,10 +2377,13 @@ class AccountConcordActions(
|
||||
suspend fun createConcordChannel(
|
||||
communityId: String,
|
||||
name: String,
|
||||
private: Boolean = false,
|
||||
accessRoleName: String? = null,
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
if (private) return createPrivateConcordChannel(session, cp, communityId, name, accessRoleName)
|
||||
val channelId = RandomInstance.bytes(32)
|
||||
val channel = ChannelEntity(name = name.trim())
|
||||
// Readers drop an empty or over-64-byte name (CORD-03 §2); never mint one.
|
||||
@@ -2326,6 +2440,244 @@ class AccountConcordActions(
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Private Channels (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) ──────────────────
|
||||
// A Private Channel reads on its own independent key. Its access list is the Roles scoped to it:
|
||||
// a Grant that opens one vends the key by Direct Invite, a Grant (or ban) that closes one rotates
|
||||
// it to the members still entitled. The protocol decisions live in ConcordPrivateChannels /
|
||||
// ConcordChannelRekey (shared with `amy`); only the network and the List write are here.
|
||||
|
||||
/**
|
||||
* A new Private Channel (CORD-03 §2): an independent key at channel epoch 0 stored in the List
|
||||
* FIRST (a lost List write would orphan the only copy), then its access Role and the channel
|
||||
* edition. Nobody holds the Role yet; granting it vends the key ([grantConcordRole]).
|
||||
*/
|
||||
private suspend fun createPrivateConcordChannel(
|
||||
session: ConcordCommunitySession,
|
||||
cp: ControlPlaneKeys,
|
||||
communityId: String,
|
||||
name: String,
|
||||
accessRoleName: String?,
|
||||
): Boolean {
|
||||
val build =
|
||||
ConcordPrivateChannels.create(
|
||||
actor = account.signer,
|
||||
cp = cp,
|
||||
communityId = communityId.hexToByteArray(),
|
||||
name = name,
|
||||
accessRoleName = accessRoleName,
|
||||
current = session.controlEditions(),
|
||||
authority = session.state.value?.authority,
|
||||
owner = session.entry.owner,
|
||||
createdAt = TimeUtils.now(),
|
||||
) ?: return false
|
||||
if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false
|
||||
build.wraps.forEach { publishConcordWrap(session.entry, it) }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh key at the NEXT
|
||||
* channel epoch — floored at the highest channel rotation seen on the wire, since a privatiser
|
||||
* may never have held an earlier generation and a reused epoch is silent and unrecoverable — plus
|
||||
* a new access Role, then the flag. Protects the future only. MANAGE_CHANNELS.
|
||||
*/
|
||||
suspend fun privatizeConcordChannel(
|
||||
communityId: String,
|
||||
channelIdHex: HexKey,
|
||||
accessRoleName: String? = null,
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
val state = session.state.value ?: return false
|
||||
val standing = state.channels[channelIdHex]?.definition ?: return false
|
||||
if (standing.private) return false
|
||||
val floor = observedChannelEpochFloor(session.entry, channelIdHex) ?: return false
|
||||
val build =
|
||||
ConcordPrivateChannels.privatize(
|
||||
actor = account.signer,
|
||||
cp = cp,
|
||||
entry = session.entry,
|
||||
channelIdHex = channelIdHex,
|
||||
standing = standing,
|
||||
accessRoleName = accessRoleName,
|
||||
current = session.controlEditions(),
|
||||
authority = state.authority,
|
||||
createdAt = TimeUtils.now(),
|
||||
observedFloor = floor,
|
||||
) ?: return false
|
||||
if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false
|
||||
build.wraps.forEach { publishConcordWrap(session.entry, it) }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest channel epoch a rotation of [channelIdHex] was ever published at, read off the
|
||||
* rekey addresses every held root derives (CORD-06 §2 addresses need no channel key), or null
|
||||
* when the read is inconclusive — a rotation at the window's top may have more above it, and
|
||||
* minting on a guess could reuse an epoch (Armada `channelEpochFloor`). 0 when none is seen or no
|
||||
* relay answers.
|
||||
*/
|
||||
private suspend fun observedChannelEpochFloor(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
): Long? {
|
||||
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
if (relays.isEmpty()) return 0
|
||||
val channelId = channelIdHex.hexToByteArray()
|
||||
val window = HashMap<HexKey, Long>()
|
||||
for (root in (listOf(entry.root) + entry.heldRoots.map { it.key }).distinct()) {
|
||||
for (epoch in 1L..MAX_PROBED_CHANNEL_EPOCH) window[ConcordChannelRekey.address(root.hexToByteArray(), channelId, epoch).publicKeyHex] = epoch
|
||||
}
|
||||
val seen = runCatching { account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.planeFilterFor(window.keys.toList())) }) }.getOrDefault(emptyList())
|
||||
val highest = seen.mapNotNull { window[it.pubKey] }.maxOrNull() ?: 0
|
||||
return if (highest >= MAX_PROBED_CHANNEL_EPOCH) null else highest
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The
|
||||
* held key stays, so its holders keep reading the private era. MANAGE_CHANNELS.
|
||||
*/
|
||||
suspend fun publicizeConcordChannel(
|
||||
communityId: String,
|
||||
channelIdHex: HexKey,
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
val standing =
|
||||
session.state.value
|
||||
?.channels
|
||||
?.get(channelIdHex)
|
||||
?.definition ?: return false
|
||||
val wrap = ConcordPrivateChannels.publicize(account.signer, cp, communityId.hexToByteArray(), channelIdHex, standing, session.controlEditions(), session.entry.owner, TimeUtils.now()) ?: return false
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
|
||||
// Channel keys reserved for a rotation in flight, keyed by (community, channel, new epoch,
|
||||
// prevcommit): a retry re-delivers the SAME key rather than minting a sibling that would split
|
||||
// the members across two keys at one epoch (Armada `mintOrReuseRotationKey`). Process-local.
|
||||
private val pendingConcordChannelRotations = ConcurrentMap<String, ByteArray>()
|
||||
|
||||
/**
|
||||
* Rotates Private Channel [channelIdHex] (a single-channel Rekey, CORD-06 §1-2) to exactly the
|
||||
* members entitled to it today plus ourselves, cutting everyone else. [removed] names who the
|
||||
* rotation cuts, for the authority check — the Rotator must hold MANAGE_CHANNELS and strictly
|
||||
* outrank each of them; null takes every known member who is not kept. Every chunk must land on
|
||||
* a relay before we adopt the new key. Returns whether the rotation was published and adopted.
|
||||
*/
|
||||
suspend fun rekeyConcordChannel(
|
||||
communityId: String,
|
||||
channelIdHex: HexKey,
|
||||
removed: Set<HexKey>? = null,
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val state = session.state.value ?: return false
|
||||
if (state.dissolved) return false
|
||||
val entry = session.entry
|
||||
val me = account.signer.pubKey
|
||||
val held = ConcordChannelKeyring.heldKey(entry, channelIdHex) ?: return false
|
||||
val authority = state.authority
|
||||
val keep = ConcordPrivateChannels.keepSet(authority, channelIdHex, me)
|
||||
val cut = removed ?: (session.allMembers() - keep)
|
||||
if (!ConcordPrivateChannels.canRotate(authority, me, cut)) {
|
||||
Log.w("Concord") { "Refusing to rotate $channelIdHex in $communityId: not MANAGE_CHANNELS, or does not outrank a cut member (CORD-06 §3)" }
|
||||
return false
|
||||
}
|
||||
val editions = session.controlEditions()
|
||||
val citation = ConcordReceive.rotationCitation(entry, editions, me)
|
||||
if (citation == null && !authority.isOwner(me)) return false
|
||||
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
if (publishTo.isEmpty()) return false
|
||||
|
||||
val newEpoch = held.epoch + 1
|
||||
val reservation = "${entry.id}:${held.channelId.lowercase()}:$newEpoch:${ConcordChannelRekey.prevCommit(held.epoch, held.key.hexToByteArray())}"
|
||||
val newKey = pendingConcordChannelRotations.getOrPut(reservation) { ConcordChannelRekey.mintKey() }
|
||||
val wraps = ConcordPrivateChannels.buildRotation(account.signer, entry.root.hexToByteArray(), held, newKey, keep, TimeUtils.now(), citation)
|
||||
for (wrap in wraps) {
|
||||
if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) {
|
||||
Log.w("Concord") { "Channel rekey of $channelIdHex aborted: a chunk was not accepted by any relay; retrying reuses the same key" }
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Adopt at once: the rotator must never keep writing under the severed key.
|
||||
val adopted =
|
||||
updateConcordEntry(entry.id) { cur ->
|
||||
if (ConcordChannelKeyring.heldKey(cur, channelIdHex)?.epoch != held.epoch) null else ConcordChannelKeyring.withRotatedKey(cur, channelIdHex, newKey.toHexKey(), newEpoch)
|
||||
}
|
||||
if (adopted) pendingConcordChannelRotations.remove(reservation)
|
||||
return adopted
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a roster change with the keys it implies (Armada `handleToggleRole`): every Private
|
||||
* Channel whose entitled set moved between [before] and the current fold ([ConcordInviteVend.accessChanges])
|
||||
* — a member who gained one is handed its key by Direct Invite (only the channels gained), and
|
||||
* one who lost one is cut by rotating it. Only keys we hold can move; a channel we can't vend or
|
||||
* rotate is logged, since a revoke that cuts nobody is the failure to hear about.
|
||||
*/
|
||||
private suspend fun reconcileConcordChannelAccess(
|
||||
communityId: String,
|
||||
before: AuthorityResolver?,
|
||||
) {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return
|
||||
val state = session.state.value ?: return
|
||||
if (before == null || state.dissolved) return
|
||||
val me = account.signer.pubKey.lowercase()
|
||||
val changes = ConcordInviteVend.accessChanges(before, state.authority, state.privateChannelIds)
|
||||
if (changes.isEmpty()) return
|
||||
|
||||
val vend = HashMap<HexKey, MutableSet<HexKey>>()
|
||||
for (change in changes) {
|
||||
val held = ConcordChannelKeyring.heldKey(session.entry, change.channelIdHex)
|
||||
if (held == null) {
|
||||
Log.w("Concord") { "Access to ${change.channelIdHex} changed, but we hold no key to vend or rotate it" }
|
||||
continue
|
||||
}
|
||||
for (member in change.gained - me) vend.getOrPut(member) { HashSet() }.add(change.channelIdHex)
|
||||
val cut = change.lost - me
|
||||
if (cut.isNotEmpty() && !rekeyConcordChannel(communityId, change.channelIdHex, cut)) {
|
||||
Log.w("Concord") { "Could not rotate ${change.channelIdHex}: ${cut.size} member(s) may keep reading it until someone who can rotates it" }
|
||||
}
|
||||
}
|
||||
for ((member, channels) in vend) {
|
||||
val sent = sendConcordDirectInvite(communityId, member, onlyChannelIds = channels)
|
||||
if (sent != ConcordDirectInviteSendResult.SENT) Log.w("Concord") { "Could not deliver ${channels.size} channel key(s) to $member: $sent" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains the buffered channel rekeys of every joined community (CORD-06 §2 receive path): for
|
||||
* each held Private Channel, a complete, honored rotation carrying our blob off the key we hold
|
||||
* moves the key forward; a complete one from a Rotator who outranks us that omits us drops it and
|
||||
* records the cut. Runs on the revision tick; idempotent once applied (the held epoch moves on).
|
||||
*/
|
||||
internal suspend fun drainConcordChannelRekeys() {
|
||||
if (!account.isWriteable()) return
|
||||
for (session in account.concordSessions.sessions()) {
|
||||
val state = session.state.value ?: continue
|
||||
// Death wins every race (CORD-02 §9).
|
||||
if (state.dissolved) continue
|
||||
val wraps = session.pendingChannelRekeyWraps()
|
||||
if (wraps.isEmpty()) continue
|
||||
val entry = session.entry
|
||||
val outcomes = ConcordPrivateChannels.receive(entry, wraps, session.controlEditions(), state.authority, account.signer)
|
||||
if (outcomes.isEmpty()) continue
|
||||
val fromEpochs = entry.privateChannels.associate { it.channelId.lowercase() to it.epoch }
|
||||
if (updateConcordEntry(entry.id) { cur -> ConcordPrivateChannels.applyOutcome(cur, outcomes, fromEpochs) }) {
|
||||
for ((id, outcome) in outcomes) {
|
||||
when (outcome) {
|
||||
is ChannelRekeyOutcome.Adopted -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: adopted epoch ${outcome.epoch}" }
|
||||
is ChannelRekeyOutcome.Removed -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: cut at epoch ${outcome.epoch}" }
|
||||
ChannelRekeyOutcome.None -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from
|
||||
* the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT
|
||||
|
||||
+22
@@ -243,6 +243,28 @@ class ConcordChannelListState(
|
||||
write(set, doc.entries.filterNot { it.id == entry.id } + live, doc.residue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-modify-write one membership: [transform] receives the entry for [communityId] **as the
|
||||
* List holds it inside the write lock** and returns its replacement, or null to write nothing.
|
||||
* Returns the fragment events to publish (empty when the community isn't held or nothing
|
||||
* changed).
|
||||
*
|
||||
* Use this, never [follow] with a snapshot, for any change that touches one field of a live
|
||||
* entry (a channel key, a cut): the List can move between reading a snapshot and writing it —
|
||||
* a rekey adopted, a new root imported — and following the stale copy would write the old
|
||||
* root back over it.
|
||||
*/
|
||||
suspend fun update(
|
||||
communityId: String,
|
||||
transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?,
|
||||
): List<Event> =
|
||||
writeLock.withLock {
|
||||
val (set, doc) = snapshot()
|
||||
val current = doc.entries.firstOrNull { it.id == communityId } ?: return@withLock emptyList()
|
||||
val next = transform(current) ?: return@withLock emptyList()
|
||||
write(set, doc.entries.map { if (it.id == communityId) next else it }, doc.residue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave [communityId]: drop its membership and tombstone it (CORD-02 §8 — only a tombstone
|
||||
* subtracts a membership; a missing entry is just unseen news another fragment may still
|
||||
|
||||
+26
-1
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordLocalEdit
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinSource
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinVerifier
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
@@ -283,6 +284,14 @@ class ConcordCommunitySession(
|
||||
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
|
||||
private val siblingRekeyWraps = LinkedHashMap<HexKey, Event>()
|
||||
|
||||
// Channel-rekey addresses (CORD-06 §2) for each held Private Channel's next epochs, under the
|
||||
// current root and the prior one (a Refounding seals its channel rekeys under the prior root,
|
||||
// CORD-06 §3) -> key. Re-derived whenever the held channel keys change, since each adoption
|
||||
// moves the window forward.
|
||||
@Volatile
|
||||
private var channelRekeyKeys: Map<HexKey, GroupKey> = ConcordPrivateChannels.watchKeys(entry)
|
||||
private val channelRekeyWraps = LinkedHashMap<HexKey, Event>()
|
||||
|
||||
// Current channel plane pubkey -> plane (channel id, key, bound epoch), refreshed on each control
|
||||
// re-fold. A Public Channel's plane derives from the root at the root epoch; a Private one's from
|
||||
// its held channel key at the channel epoch (CORD-03 §1). A Private Channel we hold no key for has
|
||||
@@ -389,6 +398,7 @@ class ConcordCommunitySession(
|
||||
address == nextBaseRekeyAddress ||
|
||||
address == siblingBaseRekeyAddress ||
|
||||
address == dissolvedAddress ||
|
||||
address in channelRekeyKeys ||
|
||||
address in historicalControlKeys ||
|
||||
lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress }
|
||||
|
||||
@@ -474,7 +484,13 @@ class ConcordCommunitySession(
|
||||
* The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address)
|
||||
* for their own isolated AUTH.
|
||||
*/
|
||||
fun auxStreamKeys(): List<GroupKey> = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey)
|
||||
fun auxStreamKeys(): List<GroupKey> = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey) + channelRekeyKeys.values
|
||||
|
||||
/** The channel-rekey addresses this session watches (CORD-06 §2), for the auxiliary subscription. */
|
||||
fun channelRekeyAddresses(): Set<HexKey> = channelRekeyKeys.keys
|
||||
|
||||
/** The buffered kind-3303 wraps seen at [channelRekeyAddresses], for the account's channel-rekey drain. */
|
||||
fun pendingChannelRekeyWraps(): List<Event> = lock.withLock { channelRekeyWraps.values.toList() }
|
||||
|
||||
/** The community's current Control Plane editions — the input a moderation edition chains onto. */
|
||||
fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) }
|
||||
@@ -540,6 +556,7 @@ class ConcordCommunitySession(
|
||||
// Control material may already have swapped in an entry carrying the new keys.
|
||||
if (privateKeySet(newEntry) == derivedPrivateKeys) return false
|
||||
entry = newEntry
|
||||
channelRekeyKeys = ConcordPrivateChannels.watchKeys(newEntry)
|
||||
true
|
||||
}
|
||||
// Nothing folded yet: the first control wrap derives the planes from the swapped-in entry.
|
||||
@@ -606,6 +623,14 @@ class ConcordCommunitySession(
|
||||
lock.withLock { siblingRekeyWraps[wrap.id] = wrap }
|
||||
return ConcordIngestOutcome.STRUCTURAL
|
||||
}
|
||||
in channelRekeyKeys -> {
|
||||
// Buffer only, like the base rekeys: opening a blob takes the account signer, and the
|
||||
// rotator's authority is judged against the fold at drain time.
|
||||
lock.withLock {
|
||||
if (channelRekeyWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
|
||||
}
|
||||
return ConcordIngestOutcome.STRUCTURAL
|
||||
}
|
||||
else -> {
|
||||
// A prior-epoch Control Plane wrap: buffer it and re-fold, so the anti-rollback
|
||||
// floor rises as the old epochs drain in. Structural — the floor can change the
|
||||
|
||||
+16
-6
@@ -75,9 +75,14 @@ sealed interface DirectInviteAcceptPlan {
|
||||
/** A community we don't hold: run the shared join path. */
|
||||
data object Join : DirectInviteAcceptPlan
|
||||
|
||||
/** A held community: store [entry] — the held one plus the newly granted Private Channel keys. */
|
||||
/**
|
||||
* A held community: store [entry] — the held one plus the newly granted Private Channel keys
|
||||
* ([channelIds]). A writer re-applies [channelIds] to the entry it reads inside the List write
|
||||
* ([ConcordInviteVend.adoptCatchUp] with `only`), never [entry] itself, which is a snapshot.
|
||||
*/
|
||||
class CatchUp(
|
||||
val entry: ConcordCommunityListEntry,
|
||||
val channelIds: List<HexKey>,
|
||||
) : DirectInviteAcceptPlan
|
||||
|
||||
/** A held community the bundle adds nothing to (or can't: a different base, or dissolved). */
|
||||
@@ -216,9 +221,11 @@ class ConcordDirectInviteInbox(
|
||||
* - not held → [DirectInviteAcceptPlan.Join] (the shared join path, which still ban-gates
|
||||
* against the community's own Control Plane);
|
||||
* - held on the SAME base with new Private Channel keys → [DirectInviteAcceptPlan.CatchUp],
|
||||
* the held entry with only those keys merged in — never moving the base (Armada
|
||||
* `catchUpChannelIds`) — unless the held roster bans [me]; refused while the roster isn't
|
||||
* folded ([DirectInviteAcceptPlan.RosterNotLoaded]);
|
||||
* the held entry with only those keys ADDED — never moving the base, never replacing a
|
||||
* held key (Armada `catchUpChannelIds`) — and only from a sender who is staff in the held
|
||||
* fold, for channels it knows as live Private Channels
|
||||
* ([ConcordInviteVend.admissibleCatchUpIds]); refused when the held roster bans [me], and
|
||||
* while it isn't folded ([DirectInviteAcceptPlan.RosterNotLoaded]);
|
||||
* - held otherwise (nothing new, a different base, dissolved) → [DirectInviteAcceptPlan.NothingNew].
|
||||
*/
|
||||
fun acceptPlan(
|
||||
@@ -230,12 +237,15 @@ class ConcordDirectInviteInbox(
|
||||
): DirectInviteAcceptPlan {
|
||||
if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired
|
||||
if (held == null) return DirectInviteAcceptPlan.Join
|
||||
val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite) ?: return DirectInviteAcceptPlan.NothingNew
|
||||
if (ConcordInviteVend.catchUpChannelIds(held, opened.invite).isEmpty()) return DirectInviteAcceptPlan.NothingNew
|
||||
if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded
|
||||
// Death wins every race (CORD-02 §9): a dissolved community takes no new keys.
|
||||
if (heldState.dissolved) return DirectInviteAcceptPlan.NothingNew
|
||||
if (heldState.authority.isBanned(me)) return DirectInviteAcceptPlan.Banned
|
||||
return DirectInviteAcceptPlan.CatchUp(adopted)
|
||||
val ids = ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender)
|
||||
if (ids.isEmpty()) return DirectInviteAcceptPlan.NothingNew
|
||||
val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite, ids) ?: return DirectInviteAcceptPlan.NothingNew
|
||||
return DirectInviteAcceptPlan.CatchUp(adopted, ids)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* 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.amethyst.commons.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordIngestOutcome
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
|
||||
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 kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Private Channels end to end, headless (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-2): create
|
||||
* with an access Role, vend on grant through a Direct Invite limited to the gained channel, the
|
||||
* recipient's click-free adoption, rotate on revoke to the remaining entitled set, and each member's
|
||||
* receive (the kept adopts, the cut drops the key and records the cut). Plus privatise/publicise.
|
||||
*/
|
||||
class ConcordPrivateChannelsTest {
|
||||
private val owner = NostrSignerInternal(KeyPair())
|
||||
private val alice = NostrSignerInternal(KeyPair())
|
||||
private val bob = NostrSignerInternal(KeyPair())
|
||||
|
||||
private class World(
|
||||
val community: NewConcordCommunity,
|
||||
val editions: MutableList<ControlEdition>,
|
||||
) {
|
||||
fun add(wrap: Event) {
|
||||
editions += ConcordActions.controlEditions(listOf(wrap), community.controlPlane)
|
||||
}
|
||||
|
||||
fun state(): ConcordCommunityState = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey)
|
||||
}
|
||||
|
||||
private suspend fun world(): World {
|
||||
val c = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
return World(c, ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList())
|
||||
}
|
||||
|
||||
private fun entryOf(
|
||||
c: NewConcordCommunity,
|
||||
channels: List<PrivateChannelKey> = emptyList(),
|
||||
) = ConcordCommunityListEntry(
|
||||
id = c.communityIdHex,
|
||||
owner = c.ownerPubKey,
|
||||
ownerSalt = c.ownerSalt.toHexKey(),
|
||||
root = c.communityRoot.toHexKey(),
|
||||
rootEpoch = c.rootEpoch,
|
||||
controlPk = c.controlPkHex,
|
||||
privateChannels = channels,
|
||||
relays = listOf("wss://relay.example"),
|
||||
name = "Nostrichs",
|
||||
addedAt = 1_000L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun aPrivateChannelIsVendedOnGrantAndRotatedOnRevoke() =
|
||||
runTest {
|
||||
val w = world()
|
||||
val c = w.community
|
||||
val cid = c.communityId
|
||||
|
||||
// 1. Create: an access Role at the bottom of the roster, the channel flagged private, a key at epoch 0.
|
||||
val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, cid, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L))
|
||||
build.wraps.forEach { w.add(it) }
|
||||
val ch = build.channelIdHex
|
||||
assertEquals(0L, build.key.epoch)
|
||||
assertTrue(ch in w.state().privateChannelIds)
|
||||
val role = assertNotNull(w.state().roles[build.roleIdHex])
|
||||
assertEquals("channel", role.scope?.kind)
|
||||
assertEquals(ch, role.scope?.channelId)
|
||||
assertEquals("0", role.permissions)
|
||||
val ownerEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
|
||||
|
||||
// 2. Grant alice and bob the access Role: both gained the channel.
|
||||
val beforeGrant = w.state().authority
|
||||
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
|
||||
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
|
||||
val granted = ConcordInviteVend.accessChanges(beforeGrant, w.state().authority, w.state().privateChannelIds).single()
|
||||
assertEquals(setOf(alice.pubKey, bob.pubKey), granted.gained)
|
||||
|
||||
// 3. Vend: a Direct Invite limited to the gained channel, from staff, adopted by alice without a click.
|
||||
val draft = assertIs<ConcordDirectInviteDraft.Ready>(ConcordActions.draftDirectInvite(ownerEntry, w.state(), owner.pubKey, alice.pubKey, onlyChannelIds = setOf(ch)))
|
||||
assertEquals(listOf(ch), draft.invite.channels.map { it.id })
|
||||
val aliceHeld = entryOf(c)
|
||||
assertEquals(
|
||||
ConcordInviteVend.CatchUpVerdict.ADOPT,
|
||||
ConcordInviteVend.judgeCatchUp(w.state().authority, w.state().privateChannelIds, alice.pubKey, owner.pubKey, draft.invite, aliceHeld),
|
||||
)
|
||||
val aliceEntry = assertNotNull(ConcordInviteVend.adoptCatchUp(aliceHeld, draft.invite))
|
||||
val bobEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
|
||||
assertEquals(build.key.key, ConcordChannelKeyring.heldKey(aliceEntry, ch)?.key)
|
||||
|
||||
// 4. Revoke bob: he lost the channel, so the owner rotates it to the remaining entitled set.
|
||||
val beforeRevoke = w.state().authority
|
||||
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, emptyList(), w.editions, 4L, owner = c.ownerPubKey))
|
||||
val revoked = ConcordInviteVend.accessChanges(beforeRevoke, w.state().authority, w.state().privateChannelIds).single()
|
||||
assertEquals(setOf(bob.pubKey), revoked.lost)
|
||||
val keep = ConcordPrivateChannels.keepSet(w.state().authority, ch, owner.pubKey)
|
||||
assertEquals(setOf(owner.pubKey, alice.pubKey), keep)
|
||||
assertTrue(ConcordPrivateChannels.canRotate(w.state().authority, owner.pubKey, revoked.lost))
|
||||
// A plain member can't rotate anyone out.
|
||||
assertFalse(ConcordPrivateChannels.canRotate(w.state().authority, alice.pubKey, setOf(bob.pubKey)))
|
||||
|
||||
val newKey = ConcordChannelRekey.mintKey()
|
||||
val held = assertNotNull(ConcordChannelKeyring.heldKey(ownerEntry, ch))
|
||||
val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, held, newKey, keep, 5L, authority = null)
|
||||
|
||||
// 5. Receive: alice adopts epoch 1, bob is cut and the cut is recorded.
|
||||
val aliceOut = ConcordPrivateChannels.receive(aliceEntry, wraps, w.editions, w.state().authority, alice)
|
||||
val adopted = assertIs<ChannelRekeyOutcome.Adopted>(aliceOut[ch])
|
||||
assertEquals(1L, adopted.epoch)
|
||||
val aliceNext = assertNotNull(ConcordPrivateChannels.applyOutcome(aliceEntry, aliceOut, mapOf(ch to 0L)))
|
||||
assertEquals(newKey.toHexKey(), ConcordChannelKeyring.heldKey(aliceNext, ch)?.key)
|
||||
assertEquals(1L, ConcordChannelKeyring.heldKey(aliceNext, ch)?.epoch)
|
||||
|
||||
val bobOut = ConcordPrivateChannels.receive(bobEntry, wraps, w.editions, w.state().authority, bob)
|
||||
assertEquals(1L, assertIs<ChannelRekeyOutcome.Removed>(bobOut[ch]).epoch)
|
||||
val bobNext = assertNotNull(ConcordPrivateChannels.applyOutcome(bobEntry, bobOut, mapOf(ch to 0L)))
|
||||
assertNull(ConcordChannelKeyring.heldKey(bobNext, ch))
|
||||
assertEquals(mapOf(ch to 1L), ConcordChannelKeyring.cutsOf(bobNext))
|
||||
// The stale epoch-0 key can't come back through a bundle.
|
||||
assertTrue(ConcordInviteVend.catchUpChannelIds(bobNext, draft.invite).isEmpty())
|
||||
|
||||
// A result computed from a stale epoch never rolls the List back.
|
||||
assertNull(ConcordPrivateChannels.applyOutcome(aliceNext, aliceOut, mapOf(ch to 0L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRotationFromAMemberWithoutAuthorityIsIgnored() =
|
||||
runTest {
|
||||
val w = world()
|
||||
val c = w.community
|
||||
val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, c.communityId, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L))
|
||||
build.wraps.forEach { w.add(it) }
|
||||
w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
|
||||
w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
|
||||
val aliceEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
|
||||
|
||||
// Bob holds the key but no MANAGE_CHANNELS: holding a key is never authority (CORD-06 §3).
|
||||
val forged = ConcordPrivateChannels.buildRotation(bob, c.communityRoot, build.key, ConcordChannelRekey.mintKey(), setOf(bob.pubKey), 5L, authority = null)
|
||||
assertTrue(ConcordPrivateChannels.receive(aliceEntry, forged, w.editions, w.state().authority, alice).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSessionWatchesAndBuffersItsChannelRekeys() =
|
||||
runTest {
|
||||
val c = world().community
|
||||
val chId = ByteArray(32) { 0x5C }
|
||||
val key = PrivateChannelKey(chId.toHexKey(), "10".repeat(32), 3, "mods")
|
||||
val entry = entryOf(c, listOf(key))
|
||||
val session = ConcordCommunitySession(entry, alice.pubKey)
|
||||
|
||||
// The next LOOKAHEAD channel epochs past the held one, under the current root.
|
||||
val next = ConcordChannelRekey.address(c.communityRoot, chId, 4).publicKeyHex
|
||||
assertTrue(next in session.channelRekeyAddresses())
|
||||
assertTrue(ConcordChannelRekey.address(c.communityRoot, chId, 3 + ConcordChannelRekey.LOOKAHEAD.toLong()).publicKeyHex in session.channelRekeyAddresses())
|
||||
assertFalse(ConcordChannelRekey.address(c.communityRoot, chId, 3).publicKeyHex in session.channelRekeyAddresses())
|
||||
assertTrue(session.ownsPlane(next))
|
||||
// Also subscribed with the auxiliary planes.
|
||||
assertTrue(ConcordSubscriptionPlanner.auxiliaryPlaneSubs(listOf(entry)).any { it.pubKeyHex == next })
|
||||
|
||||
val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, key, ConcordChannelRekey.mintKey(), setOf(alice.pubKey), 5L, null)
|
||||
assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(wraps.single()))
|
||||
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(wraps.single()))
|
||||
assertEquals(listOf(wraps.single().id), session.pendingChannelRekeyWraps().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun privatizeClimbsTheChannelEpochAndPublicizeFlipsTheFlagBack() =
|
||||
runTest {
|
||||
val w = world()
|
||||
val c = w.community
|
||||
val general = c.generalChannelIdHex
|
||||
val standing = assertNotNull(w.state().channels[general]).definition
|
||||
assertFalse(standing.private)
|
||||
|
||||
val build = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, "insiders", w.editions, w.state().authority, 2L))
|
||||
build.wraps.forEach { w.add(it) }
|
||||
// The first privatisation is epoch 1 (CORD-03 §2); a floor seen on the wire lifts it.
|
||||
assertEquals(1L, build.key.epoch)
|
||||
assertTrue(general in w.state().privateChannelIds)
|
||||
assertEquals("insiders", w.state().roles[build.roleIdHex]?.name)
|
||||
val later = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, null, w.editions, w.state().authority, 2L, observedFloor = 4))
|
||||
assertEquals(5L, later.key.epoch)
|
||||
// Already private: nothing to do.
|
||||
assertNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, w.state().channels[general]!!.definition, null, w.editions, w.state().authority, 2L))
|
||||
|
||||
val flip = assertNotNull(ConcordPrivateChannels.publicize(owner, c.controlPlane, c.communityId, general, w.state().channels[general]!!.definition, w.editions, c.ownerPubKey, 3L))
|
||||
w.add(flip)
|
||||
assertFalse(general in w.state().privateChannelIds)
|
||||
// The held private-era key keeps reading its history once the channel is public again.
|
||||
val heldEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
|
||||
val planes = ConcordActions.historicalChannelPlanes(heldEntry, general, isPrivate = false)
|
||||
assertTrue(planes.any { it.epoch == 1L })
|
||||
// And the next privatisation climbs past it.
|
||||
assertEquals(2L, ConcordChannelKeyring.nextChannelEpoch(heldEntry, general))
|
||||
assertTrue(general.hexToByteArray().size == 32)
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -27,8 +27,10 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel
|
||||
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
|
||||
@@ -91,6 +93,13 @@ class ConcordDirectInviteInboxTest {
|
||||
|
||||
private fun stateOf(c: NewConcordCommunity): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(c.genesisWraps, c.controlPlane), c.communityId, c.ownerPubKey)
|
||||
|
||||
/** [c]'s fold with [vip] defined as a live Private Channel. */
|
||||
private suspend fun stateWithVip(c: NewConcordCommunity): ConcordCommunityState {
|
||||
val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList()
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineChannel(owner, c.controlPlane, c.communityId, vip.hexToByteArray(), ChannelEntity(name = "vip", private = true), editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane)
|
||||
return ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aValidWrapIsParkedWithItsVerifiedSenderAndDedupedByWrapId() =
|
||||
runTest {
|
||||
@@ -221,17 +230,23 @@ class ConcordDirectInviteInboxTest {
|
||||
runTest {
|
||||
val c = community()
|
||||
val held = heldEntryOf(c)
|
||||
val state = stateOf(c)
|
||||
val state = stateWithVip(c)
|
||||
val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip"))
|
||||
|
||||
// Same base, new key: a catch-up that keeps the held base and anchor.
|
||||
val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me))
|
||||
// Same base, new key, from staff (the owner): a catch-up that keeps the held base and anchor.
|
||||
val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = grant)), me))
|
||||
val plan = assertIs<DirectInviteAcceptPlan.CatchUp>(ConcordDirectInviteInbox.acceptPlan(catchUp, held, state, me.pubKey))
|
||||
assertEquals(held.root, plan.entry.root)
|
||||
assertEquals(held.rootEpoch, plan.entry.rootEpoch)
|
||||
assertEquals(held.controlPk, plan.entry.controlPk)
|
||||
assertEquals("anchor", plan.entry.inviteRef)
|
||||
assertEquals(listOf(vip), plan.entry.privateChannels.map { it.channelId })
|
||||
assertEquals(listOf(vip), plan.channelIds)
|
||||
|
||||
// A plain keyholder can't plant a key, and a channel the fold doesn't know as Private isn't one.
|
||||
val fromMember = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me))
|
||||
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(fromMember, held, state, me.pubKey))
|
||||
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, held, stateOf(c), me.pubKey))
|
||||
|
||||
// No fold yet: the ban verdict is unknown, so it waits.
|
||||
assertEquals(DirectInviteAcceptPlan.RosterNotLoaded, ConcordDirectInviteInbox.acceptPlan(catchUp, held, null, me.pubKey))
|
||||
@@ -240,6 +255,11 @@ class ConcordDirectInviteInboxTest {
|
||||
val holding = held.let { ConcordCommunityListEntry(it.id, it.owner, it.ownerSalt, it.root, it.rootEpoch, it.controlPk, privateChannels = listOf(PrivateChannelKey(vip, "db".repeat(32), 0, "vip")), relays = it.relays, name = it.name) }
|
||||
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, holding, state, me.pubKey))
|
||||
|
||||
// Even from staff, a bundle never REPLACES a held key — not at a higher, nor an absurd, epoch.
|
||||
// A held key moves only through a channel rekey, whose prevcommit proves continuity.
|
||||
val hijack = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "ee".repeat(32), 1_000_000_000L, "vip")))), me))
|
||||
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(hijack, holding, state, me.pubKey))
|
||||
|
||||
// A different base for a held community is never adopted, keys or not.
|
||||
val baseMove = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, root = "99".repeat(32), channels = grant)), me))
|
||||
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(baseMove, held, state, me.pubKey))
|
||||
|
||||
Reference in New Issue
Block a user