mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge branch 'worktree-agent-a01559dd1ccb23d6b' into claude/hopeful-brown-1suxdw
# Conflicts: # cli/README.md # cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt # cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt # commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt # quartz/plans/2026-09-29-concord-spec-conformance.md # quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt
This commit is contained in:
+5
-2
@@ -679,9 +679,12 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List
|
|||||||
| `amy concord channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). |
|
| `amy concord channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). |
|
||||||
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. |
|
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. |
|
||||||
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). |
|
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). |
|
||||||
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. |
|
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator). |
|
||||||
| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. |
|
| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. |
|
||||||
| `amy concord join URL` | Redeem an invite link and save the community. |
|
| `amy concord join URL` | Redeem an invite link, save the community, and publish a Guestbook Join echoing the link's attribution (CORD-05 §1/§6). |
|
||||||
|
| `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). |
|
||||||
|
| `amy concord recover [COMMUNITY] [--rejoin]` | Report whether a Refounding left us behind (our joined-through link resolves to a higher epoch). A bundle never moves the base on its own (CORD-06 §2); `--rejoin` explicitly re-accepts the link. Ban-gated, fails closed. |
|
||||||
|
| `amy concord refound COMMUNITY --remove U[,U…]` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after; reserves its keys so a re-run resumes with the same root; refused for a dissolved community. |
|
||||||
| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). |
|
| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). |
|
||||||
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`; also `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `MANAGE_MESSAGES`, `CREATE_INVITE`, `VIEW_AUDIT_LOG`, `MENTION_EVERYONE`, `PIN_MESSAGES`). |
|
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`; also `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `MANAGE_MESSAGES`, `CREATE_INVITE`, `VIEW_AUDIT_LOG`, `MENTION_EVERYONE`, `PIN_MESSAGES`). |
|
||||||
| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. |
|
| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. |
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
|
|||||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||||
|
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
|
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
|
||||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||||
@@ -46,6 +47,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
|
|||||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
|
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
|
||||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
|
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
|
||||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
|
||||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
@@ -79,9 +81,11 @@ object ConcordCommands {
|
|||||||
| concord join URL redeem an invite link and save the community
|
| concord join URL redeem an invite link and save the community
|
||||||
| concord rekey [COMMUNITY] follow a Refounding we were re-keyed for:
|
| concord rekey [COMMUNITY] follow a Refounding we were re-keyed for:
|
||||||
| open our blob and adopt the new epoch
|
| open our blob and adopt the new epoch
|
||||||
| concord recover [COMMUNITY] re-resolve the joined-through invite link and
|
| concord recover [COMMUNITY] [--rejoin] re-resolve the joined-through invite link and
|
||||||
| follow a Refounding we were left out of
|
| report whether a Refounding left us behind;
|
||||||
| (CORD-06); refuses if that epoch banned us
|
| --rejoin re-accepts that link (a bundle never
|
||||||
|
| moves the base on its own, CORD-06 §2);
|
||||||
|
| refuses if that epoch banned us
|
||||||
| concord roles COMMUNITY list live roles + current banlist (CORD-04)
|
| concord roles COMMUNITY list live roles + current banlist (CORD-04)
|
||||||
| concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK)
|
| concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK)
|
||||||
| concord grant COMMUNITY USER ROLE-ID grant a role to a member
|
| concord grant COMMUNITY USER ROLE-ID grant a role to a member
|
||||||
@@ -285,7 +289,9 @@ object ConcordCommands {
|
|||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
// The joiner cannot derive the Control Plane address, so the invite carries it
|
// 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.
|
// (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 })
|
// The creator rides in the bundle so joiners echo it in their Guestbook Join (CORD-05 §1).
|
||||||
|
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null }, creator = ctx.signer.pubKey)
|
||||||
|
// At most 3 bootstrap relays ride in the fragment (CORD-05 §3); the codec truncates.
|
||||||
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays)
|
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays)
|
||||||
// Record the link BEFORE publishing the bundle (CORD-05, kind 13303): a link whose
|
// Record the link BEFORE publishing the bundle (CORD-05, kind 13303): a link whose
|
||||||
// `signer_sk` was never stored can never be refreshed, so the next Refounding orphans
|
// `signer_sk` was never stored can never be refreshed, so the next Refounding orphans
|
||||||
@@ -433,7 +439,7 @@ object ConcordCommands {
|
|||||||
// relay that kept the old version hand out a link its creator revoked — and it cannot
|
// relay that kept the old version hand out a link its creator revoked — and it cannot
|
||||||
// tell the user which of "revoked", "expired" or "gone" they are looking at.
|
// tell the user which of "revoked", "expired" or "gone" they are looking at.
|
||||||
val bundle =
|
val bundle =
|
||||||
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
|
when (val status = ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token)) {
|
||||||
is InviteBundleStatus.Live -> status.invite
|
is InviteBundleStatus.Live -> status.invite
|
||||||
is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined")
|
is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined")
|
||||||
InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator")
|
InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator")
|
||||||
@@ -469,7 +475,7 @@ object ConcordCommands {
|
|||||||
return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)")
|
return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)")
|
||||||
}
|
}
|
||||||
|
|
||||||
ConcordStore(dataDir.concordFile).upsert(
|
val stored =
|
||||||
StoredCommunity(
|
StoredCommunity(
|
||||||
name = bundle.name,
|
name = bundle.name,
|
||||||
communityId = bundle.communityId,
|
communityId = bundle.communityId,
|
||||||
@@ -485,15 +491,53 @@ object ConcordCommands {
|
|||||||
// this link is the only way back (CORD-05/06). Stored bare, domain-agnostic.
|
// this link is the only way back (CORD-05/06). Stored bare, domain-agnostic.
|
||||||
inviteRef = ConcordActions.bareInviteRef(url) ?: "",
|
inviteRef = ConcordActions.bareInviteRef(url) ?: "",
|
||||||
privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
|
privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
|
||||||
),
|
)
|
||||||
)
|
ConcordStore(dataDir.concordFile).upsert(stored)
|
||||||
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays))
|
|
||||||
|
// Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later
|
||||||
|
// Refounding finds this member to re-key, and it echoes the link's attribution so link
|
||||||
|
// holders can count per-link joins. Best-effort, like every Guestbook motion.
|
||||||
|
val announced = announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label)
|
||||||
|
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced))
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- shared helpers (used by ConcordChannelCommands too) ------------------
|
// ---- shared helpers (used by ConcordChannelCommands too) ------------------
|
||||||
|
|
||||||
|
private val HEX64 = Regex("^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
/** Publishes a Guestbook Join for [sc] at its current epoch, echoing invite attribution; true if a relay took it. */
|
||||||
|
suspend fun announceGuestbookJoin(
|
||||||
|
ctx: Context,
|
||||||
|
sc: StoredCommunity,
|
||||||
|
inviteCreator: String?,
|
||||||
|
inviteLabel: String?,
|
||||||
|
): Boolean {
|
||||||
|
val creator = inviteCreator?.lowercase()?.takeIf { HEX64.matches(it) }
|
||||||
|
val label = inviteLabel?.takeIf { creator != null && it.isNotBlank() }
|
||||||
|
val guestbook = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
|
||||||
|
val wrap = ConcordActions.buildGuestbookJoin(ctx.signer, guestbook, TimeUtils.now(), creator, label)
|
||||||
|
val relays = relaysFor(ctx, sc)
|
||||||
|
ctx.registerConcordStreamKeys(relays, listOf(guestbook.secretKey))
|
||||||
|
return ctx.publish(wrap, relays).values.any { it.accepted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [sc] carries a valid owner tombstone (CORD-02 §9). Death wins every race: no rekey,
|
||||||
|
* recovery or Refounding moves a dissolved community forward.
|
||||||
|
*/
|
||||||
|
suspend fun isDissolved(
|
||||||
|
ctx: Context,
|
||||||
|
sc: StoredCommunity,
|
||||||
|
): Boolean {
|
||||||
|
val grave = ConcordDissolution.planeKey(sc.communityId)
|
||||||
|
val relays = relaysFor(ctx, sc)
|
||||||
|
ctx.registerConcordStreamKeys(relays, listOf(grave.secretKey))
|
||||||
|
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(grave.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||||
|
return ConcordDissolution.isDissolved(wraps, sc.communityId, sc.owner)
|
||||||
|
}
|
||||||
|
|
||||||
fun parseRelays(csv: String?): List<String> = csv?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
|
fun parseRelays(csv: String?): List<String> = csv?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
|
||||||
|
|
||||||
fun normalize(urls: List<String>): Set<NormalizedRelayUrl> = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
fun normalize(urls: List<String>): Set<NormalizedRelayUrl> = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||||
@@ -577,23 +621,21 @@ object ConcordCommands {
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `concord recover [COMMUNITY]` — the stranded-recovery receive path (CORD-05/06 A2).
|
* `concord recover [COMMUNITY] [--rejoin]` — stranded detection (CORD-05/06).
|
||||||
*
|
*
|
||||||
* A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a
|
* A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a
|
||||||
* member simply left out of the rekey receives nothing and sits on the dead epoch forever while
|
* member simply left out of the rekey receives nothing and sits on the dead epoch while
|
||||||
* everyone else moves on. There is no message to miss, which is why the rekey drain cannot help.
|
* everyone else moves on. The invite link the membership was joined through is re-minted at
|
||||||
* The way back is the invite link the membership was joined through: the community keeps
|
* the current epoch, so a live bundle there at a **strictly higher** epoch says we were left
|
||||||
* re-minting its bundle at the same addressable coordinate, so a live bundle at a **strictly
|
* behind.
|
||||||
* higher** epoch than ours proves we were left behind — and carries the new root.
|
|
||||||
*
|
*
|
||||||
* Amethyst sweeps this on a timer; amy makes it an explicit verb, so it stays deterministic and
|
* A bundle is NOT proof of continuity (nothing binds `community_root` to `community_id`), so
|
||||||
* scriptable rather than a background loop.
|
* this verb only reports by default — a link creator must not be able to relocate everyone who
|
||||||
|
* joined through their link (CORD-06 §2: the base moves only by a verifiable rekey).
|
||||||
|
* `--rejoin` is the user explicitly re-accepting that link: the same trust decision as `join`.
|
||||||
*
|
*
|
||||||
* The ban gate is the point of care. A removed member keeps the link's unlock token forever, so
|
* Ban-gated at the epoch we are leaving and **fails closed** (no fold, no verdict, no rejoin);
|
||||||
* without it this walks them straight back into the epoch they were rotated out of. It reads the
|
* a dissolved community is never moved (CORD-02 §9).
|
||||||
* banlist of the epoch we are **leaving** (the last Control Plane we can still fold) and **fails
|
|
||||||
* closed**: a community whose plane will not fold yields no verdict and is skipped, never
|
|
||||||
* recovered.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun recover(
|
private suspend fun recover(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
@@ -601,6 +643,7 @@ object ConcordCommands {
|
|||||||
): Int {
|
): Int {
|
||||||
val args = Args(rest)
|
val args = Args(rest)
|
||||||
val handle = args.positionalOrNull(0)
|
val handle = args.positionalOrNull(0)
|
||||||
|
val rejoin = args.bool("rejoin")
|
||||||
args.rejectUnknown()
|
args.rejectUnknown()
|
||||||
val store = ConcordStore(dataDir.concordFile)
|
val store = ConcordStore(dataDir.concordFile)
|
||||||
val targets =
|
val targets =
|
||||||
@@ -614,54 +657,68 @@ object ConcordCommands {
|
|||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
val results = mutableListOf<Map<String, Any?>>()
|
val results = mutableListOf<Map<String, Any?>>()
|
||||||
for (sc in targets) {
|
for (sc in targets) {
|
||||||
|
fun skip(reason: String) = mapOf("community_id" to sc.communityId, "name" to sc.name, "stranded" to false, "recovered" to false, "reason" to reason)
|
||||||
|
|
||||||
val inviteRef = sc.inviteRef.ifBlank { null }
|
val inviteRef = sc.inviteRef.ifBlank { null }
|
||||||
if (inviteRef == null) {
|
if (inviteRef == null) {
|
||||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_invite_ref")
|
results += skip("no_invite_ref")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val parsed = ConcordActions.parseInviteLink(inviteRef)
|
val parsed = ConcordActions.parseInviteLink(inviteRef)
|
||||||
if (parsed == null) {
|
if (parsed == null) {
|
||||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "bad_invite_ref")
|
results += skip("bad_invite_ref")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isDissolved(ctx, sc)) {
|
||||||
|
results += skip("dissolved")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() }
|
val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() }
|
||||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second }
|
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second }
|
||||||
// Only a LIVE bundle recovers: an expired or revoked link is not a rotation we missed.
|
// Only a LIVE bundle counts: an expired or revoked link is not a rotation we missed.
|
||||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite
|
val bundle = (ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite
|
||||||
if (bundle == null) {
|
if (bundle == null) {
|
||||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_live_bundle")
|
results += skip("no_live_bundle")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict,
|
// Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict.
|
||||||
// no recovery — the gate fails closed rather than assuming "not banned".
|
|
||||||
val cp = controlPlaneKeysFor(sc)
|
val cp = controlPlaneKeysFor(sc)
|
||||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
|
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
|
||||||
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||||
val editions = ConcordActions.controlEditions(controlWraps, cp)
|
val editions = ConcordActions.controlEditions(controlWraps, cp)
|
||||||
if (editions.isEmpty()) {
|
if (editions.isEmpty()) {
|
||||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "control_plane_not_folded")
|
results += skip("control_plane_not_folded")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val bannedHere = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner).isBanned(ctx.signer.pubKey)
|
val bannedHere = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner).isBanned(ctx.signer.pubKey)
|
||||||
|
val entry = entryFor(sc)
|
||||||
val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere)
|
if (!ConcordActions.isStranded(entry, bundle, bannedHere)) {
|
||||||
if (merged == null) {
|
results += skip(if (bannedHere) "banned" else "already_current") + ("root_epoch" to sc.rootEpoch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!rejoin) {
|
||||||
results +=
|
results +=
|
||||||
mapOf(
|
mapOf(
|
||||||
"community_id" to sc.communityId,
|
"community_id" to sc.communityId,
|
||||||
"name" to sc.name,
|
"name" to sc.name,
|
||||||
|
"stranded" to true,
|
||||||
"recovered" to false,
|
"recovered" to false,
|
||||||
"reason" to if (bannedHere) "banned" else "already_current",
|
"reason" to "rejoin_required",
|
||||||
"root_epoch" to sc.rootEpoch,
|
"root_epoch" to sc.rootEpoch,
|
||||||
|
"link_epoch" to bundle.rootEpoch,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
store.upsert(storedFrom(sc, merged))
|
val merged = ConcordActions.rejoinStranded(entry, bundle, bannedHere) ?: continue
|
||||||
|
val stored = storedFrom(sc, merged)
|
||||||
|
store.upsert(stored)
|
||||||
|
announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label)
|
||||||
results +=
|
results +=
|
||||||
mapOf(
|
mapOf(
|
||||||
"community_id" to sc.communityId,
|
"community_id" to sc.communityId,
|
||||||
"name" to sc.name,
|
"name" to sc.name,
|
||||||
|
"stranded" to true,
|
||||||
"recovered" to true,
|
"recovered" to true,
|
||||||
"from_epoch" to sc.rootEpoch,
|
"from_epoch" to sc.rootEpoch,
|
||||||
"root_epoch" to merged.rootEpoch,
|
"root_epoch" to merged.rootEpoch,
|
||||||
@@ -681,8 +738,11 @@ object ConcordCommands {
|
|||||||
* strands every other CLI member even though their blob is sitting on the relay.
|
* strands every other CLI member even though their blob is sitting on the relay.
|
||||||
*
|
*
|
||||||
* The rotator is authorized against the roster of the epoch being **left** — `hasPermission`,
|
* The rotator is authorized against the roster of the epoch being **left** — `hasPermission`,
|
||||||
* never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed:
|
* never `effectivePermissions`, so a banned BAN-holder cannot rotate us — and must cite the
|
||||||
* a plane that will not fold yields no verdict and the community is skipped.
|
* Grant it acts under (`vac`, CORD-06 §3) at a version that fold has synced; the owner cites
|
||||||
|
* nothing. Racing honored rotations converge on the lowest new root. Fails closed: a plane that
|
||||||
|
* will not fold yields no verdict and the community is skipped. A dissolved community is never
|
||||||
|
* moved (CORD-02 §9).
|
||||||
*/
|
*/
|
||||||
private suspend fun rekey(
|
private suspend fun rekey(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
@@ -698,21 +758,12 @@ object ConcordCommands {
|
|||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
val results = mutableListOf<Map<String, Any?>>()
|
val results = mutableListOf<Map<String, Any?>>()
|
||||||
for (sc in targets) {
|
for (sc in targets) {
|
||||||
|
if (isDissolved(ctx, sc)) {
|
||||||
|
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "dissolved", "root_epoch" to sc.rootEpoch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
val relays = relaysFor(ctx, sc)
|
val relays = relaysFor(ctx, sc)
|
||||||
val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
|
// Authorize against the epoch we are LEAVING — the last plane we can fold.
|
||||||
ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey))
|
|
||||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
|
||||||
val received =
|
|
||||||
ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch)
|
|
||||||
if (received == null) {
|
|
||||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (received.newEpoch <= sc.rootEpoch) {
|
|
||||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Authorize the rotator against the epoch we are LEAVING — the last plane we can fold.
|
|
||||||
val cp = controlPlaneKeysFor(sc)
|
val cp = controlPlaneKeysFor(sc)
|
||||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
|
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
|
||||||
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||||
@@ -721,12 +772,28 @@ object ConcordCommands {
|
|||||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded")
|
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner), received.rotator)) {
|
val entry = entryFor(sc)
|
||||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator)
|
val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||||
|
val honored = { r: ReceivedRefounding -> ConcordReceive.isHonoredRotation(entry, editions, authority, r) }
|
||||||
|
|
||||||
|
val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
|
||||||
|
ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey))
|
||||||
|
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||||
|
val received = ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch, accept = honored)
|
||||||
|
if (received == null) {
|
||||||
|
// Distinguish "no blob at all" from "only rotations we refuse to honor".
|
||||||
|
val any = ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch)
|
||||||
|
val reason = if (any == null) "no_blob_for_us" else "unauthorized_rotator"
|
||||||
|
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to reason, "root_epoch" to sc.rootEpoch) + (if (any != null) mapOf("rotator" to any.rotator) else emptyMap())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
if (received.newEpoch <= sc.rootEpoch) {
|
||||||
store.upsert(storedFrom(sc, adopted))
|
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val adopted = ConcordReceive.withAdoptedRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
||||||
|
// A rotation we adopted supersedes any Refounding of ours still reserved.
|
||||||
|
store.upsert(storedFrom(sc, adopted).copy(pendingRefounding = null))
|
||||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator)
|
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator)
|
||||||
}
|
}
|
||||||
Output.emit(mapOf("communities" to results))
|
Output.emit(mapOf("communities" to results))
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.DataDir
|
|||||||
import com.vitorpamplona.amethyst.cli.Output
|
import com.vitorpamplona.amethyst.cli.Output
|
||||||
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
|
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
|
||||||
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
||||||
|
import com.vitorpamplona.amethyst.cli.stores.StoredPendingRefounding
|
||||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||||
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||||
@@ -36,6 +37,9 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
|||||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
|
||||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
@@ -286,6 +290,11 @@ object ConcordModCommands {
|
|||||||
.toSet()
|
.toSet()
|
||||||
if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user")
|
if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user")
|
||||||
|
|
||||||
|
// Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored.
|
||||||
|
if (ConcordCommands.isDissolved(ctx, sc)) {
|
||||||
|
return Output.error("dissolved", "community '$handle' has been dissolved; a Refounding cannot cross the tombstone (CORD-02 §9)")
|
||||||
|
}
|
||||||
|
|
||||||
val loaded = load(ctx, sc, dataDir)
|
val loaded = load(ctx, sc, dataDir)
|
||||||
val (cp, editions) = loaded
|
val (cp, editions) = loaded
|
||||||
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
|
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||||
@@ -306,8 +315,22 @@ object ConcordModCommands {
|
|||||||
// split epoch it takes the current control_root (CORD-02 §2).
|
// split epoch it takes the current control_root (CORD-02 §2).
|
||||||
writeGuard(cp)?.let { return it }
|
writeGuard(cp)?.let { return it }
|
||||||
|
|
||||||
|
// The rotation cites the Grant it acts under (CORD-06 §3 "Authority"), or no receiver
|
||||||
|
// honors it; the owner cites nothing.
|
||||||
|
val citation = ConcordReceive.rotationCitation(ConcordCommands.entryFor(loaded.community), editions, me)
|
||||||
|
if (citation == null && !authority.isOwner(me)) {
|
||||||
|
return Output.error("forbidden", "no Grant of yours in this community's fold to cite; receivers would drop the rotation (CORD-06 §3)")
|
||||||
|
}
|
||||||
|
|
||||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||||
|
|
||||||
|
// 0. Acquire the WHOLE plane before the first publish (CORD-06 §3: a Refounder that cannot
|
||||||
|
// fold every Control event must abort). Paged to completion, not a single capped REQ.
|
||||||
|
val swept = ctx.drainAllPages(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }).map { it.second }
|
||||||
|
if (swept.isEmpty()) {
|
||||||
|
return Output.error("control_plane_unreadable", "could not page this community's Control Plane; refusing to compact a partial plane (CORD-06 §3)")
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the
|
// 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the
|
||||||
// new epoch — carries the ban. Each edition chains onto the updated banlist head.
|
// new epoch — carries the ban. Each edition chains onto the updated banlist head.
|
||||||
var chain = editions
|
var chain = editions
|
||||||
@@ -334,45 +357,67 @@ object ConcordModCommands {
|
|||||||
|
|
||||||
// 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff
|
// 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff
|
||||||
// get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one).
|
// get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one).
|
||||||
val newRoot = RandomInstance.bytes(32)
|
// The keys are RESERVED and persisted before anything is published, so a retried
|
||||||
val newControlRoot = RandomInstance.bytes(32)
|
// `refound` re-delivers the same root instead of minting a sibling (CORD-06 §3).
|
||||||
// Compact from what we KNOW the plane holds: the wraps we drained plus the bans we just
|
val priorRoot = sc.root.hexToByteArray()
|
||||||
|
val keys =
|
||||||
|
ConcordRefounding.reserveKeys(
|
||||||
|
loaded.community.pendingRefounding?.let { PendingRefounding(sc.communityId, it.rootEpoch, it.prevCommit, it.newRoot.hexToByteArray(), it.newControlRoot.hexToByteArray()) },
|
||||||
|
sc.communityId,
|
||||||
|
sc.rootEpoch,
|
||||||
|
priorRoot,
|
||||||
|
)
|
||||||
|
val reserved = loaded.community.copy(pendingRefounding = StoredPendingRefounding(keys.rootEpoch, keys.prevCommit, keys.newRoot.toHexKey(), keys.newControlRoot.toHexKey()))
|
||||||
|
ConcordStore(dataDir.concordFile).upsert(reserved)
|
||||||
|
// Compact from what we KNOW the plane holds: the paged sweep plus the bans we just
|
||||||
// published. Re-draining alone would race the relay's indexing, and a relay that has not
|
// published. Re-draining alone would race the relay's indexing, and a relay that has not
|
||||||
// yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch
|
// yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch
|
||||||
// whose roster never banned the member we are removing.
|
// whose roster never banned the member we are removing.
|
||||||
val drained = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
val controlWraps = (swept + banWraps).distinctBy { it.id }
|
||||||
val controlWraps = (drained + banWraps).distinctBy { it.id }
|
|
||||||
val build =
|
val build =
|
||||||
ConcordActions.buildRefounding(
|
try {
|
||||||
rotatorSigner = ctx.signer,
|
ConcordActions.buildRefounding(
|
||||||
communityId = sc.communityId,
|
rotatorSigner = ctx.signer,
|
||||||
priorRoot = sc.root.hexToByteArray(),
|
communityId = sc.communityId,
|
||||||
newRoot = newRoot,
|
priorRoot = priorRoot,
|
||||||
newControlRoot = newControlRoot,
|
newRoot = keys.newRoot,
|
||||||
rootEpoch = sc.rootEpoch,
|
newControlRoot = keys.newControlRoot,
|
||||||
priorControlWraps = controlWraps,
|
rootEpoch = sc.rootEpoch,
|
||||||
priorControlKeys = cp,
|
priorControlWraps = controlWraps,
|
||||||
recipientsXOnly = recipients,
|
priorControlKeys = cp,
|
||||||
staffXOnly = authority.staffMembers(),
|
recipientsXOnly = recipients,
|
||||||
createdAt = TimeUtils.now(),
|
staffXOnly = authority.staffMembers(),
|
||||||
ownerPubKey = sc.owner,
|
createdAt = TimeUtils.now(),
|
||||||
)
|
ownerPubKey = sc.owner,
|
||||||
|
authority = citation,
|
||||||
|
// Every head our own fold honors (bans included) must survive the compaction.
|
||||||
|
mustCarry = ConcordRefounding.headVersions(chain, sc.communityId.hexToByteArray(), sc.owner),
|
||||||
|
)
|
||||||
|
} catch (e: IncompleteControlPlaneException) {
|
||||||
|
return Output.error("control_plane_incomplete", "${e.missing.size} Control Plane head(s) could not be carried into the new epoch; aborted before publishing the rotation (CORD-06 §3)")
|
||||||
|
}
|
||||||
|
|
||||||
// 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it).
|
// 4. The root roll FIRST, every chunk confirmed; the compacted plane only after it
|
||||||
build.controlWraps.forEach { ctx.publish(it, relays) }
|
// (CORD-06 §3). A chunk no relay took aborts with nothing adopted — the reserved keys
|
||||||
build.rekeyWraps.forEach { ctx.publish(it, relays) }
|
// make re-running this command re-deliver the same root.
|
||||||
|
for (wrap in build.rekeyWraps) {
|
||||||
|
if (ctx.publish(wrap, relays).values.none { it.accepted }) {
|
||||||
|
return Output.error("rekey_not_published", "a rekey chunk was not accepted by any relay; re-run to resume with the same keys")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val compactionFailures = build.controlWraps.count { wrap -> ctx.publish(wrap, relays).values.none { it.accepted } }
|
||||||
|
|
||||||
// 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the
|
// 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the
|
||||||
// epoch we are leaving for the anti-rollback floor.
|
// epoch we are leaving for the anti-rollback floor — and drop the reservation.
|
||||||
val adopted =
|
val adopted =
|
||||||
ConcordReceive.withAdoptedRoot(
|
ConcordReceive.withAdoptedRoot(
|
||||||
ConcordCommands.entryFor(loaded.community),
|
ConcordCommands.entryFor(loaded.community),
|
||||||
newRoot,
|
keys.newRoot,
|
||||||
build.newEpoch,
|
build.newEpoch,
|
||||||
build.newControlKeys.address.hexToByteArray(),
|
build.newControlKeys.address.hexToByteArray(),
|
||||||
newControlRoot,
|
keys.newControlRoot,
|
||||||
)
|
)
|
||||||
val stored = ConcordCommands.storedFrom(loaded.community, adopted)
|
val stored = ConcordCommands.storedFrom(loaded.community, adopted).copy(pendingRefounding = null)
|
||||||
ConcordStore(dataDir.concordFile).upsert(stored)
|
ConcordStore(dataDir.concordFile).upsert(stored)
|
||||||
|
|
||||||
// 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new
|
// 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new
|
||||||
@@ -400,7 +445,7 @@ object ConcordModCommands {
|
|||||||
// grants, icon, label — survive the rotation, and so a coordinate whose newest
|
// grants, icon, label — survive the rotation, and so a coordinate whose newest
|
||||||
// event is a revocation tombstone is left revoked instead of being re-opened.
|
// event is a revocation tombstone is left revoked instead of being re-opened.
|
||||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second }
|
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second }
|
||||||
val live = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching
|
val live = ConcordActions.classifyInvite(wraps, link.signerPubKeyHex(), token) as? InviteBundleStatus.Live ?: return@runCatching
|
||||||
val moved =
|
val moved =
|
||||||
live.invite.copy(
|
live.invite.copy(
|
||||||
communityRoot = stored.root,
|
communityRoot = stored.root,
|
||||||
@@ -422,6 +467,7 @@ object ConcordModCommands {
|
|||||||
"recipients" to recipients.size,
|
"recipients" to recipients.size,
|
||||||
"control_wraps" to build.controlWraps.size,
|
"control_wraps" to build.controlWraps.size,
|
||||||
"rekey_wraps" to build.rekeyWraps.size,
|
"rekey_wraps" to build.rekeyWraps.size,
|
||||||
|
"compaction_failures" to compactionFailures,
|
||||||
"invites_refreshed" to refreshed,
|
"invites_refreshed" to refreshed,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ data class StoredCommunity(
|
|||||||
// A private channel is read and written ONLY on the plane its own key derives; without one it
|
// A private channel is read and written ONLY on the plane its own key derives; without one it
|
||||||
// is unreadable and `send` refuses rather than fall back to the root-derived plane.
|
// is unreadable and `send` refuses rather than fall back to the root-derived plane.
|
||||||
val privateChannels: List<StoredPrivateChannel> = emptyList(),
|
val privateChannels: List<StoredPrivateChannel> = emptyList(),
|
||||||
|
// Keys reserved for a Refounding this account started but has not yet adopted (CORD-06 §3): a
|
||||||
|
// retried `refound` must re-deliver the SAME root, never mint a sibling that splits the members.
|
||||||
|
val pendingRefounding: StoredPendingRefounding? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** A held Private Channel key at its channel epoch, mirroring quartz `PrivateChannelKey`. */
|
/** A held Private Channel key at its channel epoch, mirroring quartz `PrivateChannelKey`. */
|
||||||
@@ -67,6 +70,14 @@ data class StoredPrivateChannel(
|
|||||||
val name: String = "",
|
val name: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** A Refounding's reserved keys, mirroring quartz `PendingRefounding`. */
|
||||||
|
data class StoredPendingRefounding(
|
||||||
|
val rootEpoch: Long = 0,
|
||||||
|
val prevCommit: String = "",
|
||||||
|
val newRoot: String = "",
|
||||||
|
val newControlRoot: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
/** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */
|
/** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */
|
||||||
data class StoredHeldRoot(
|
data class StoredHeldRoot(
|
||||||
val epoch: Long = 0,
|
val epoch: Long = 0,
|
||||||
|
|||||||
+48
-9
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
|||||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
|
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||||
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
|
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
|
||||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite
|
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite
|
||||||
@@ -281,6 +282,19 @@ object ConcordActions {
|
|||||||
rootEpoch: Long,
|
rootEpoch: Long,
|
||||||
): GroupKey = ConcordKeyDerivation.baseRekeyAddress(communityRoot, communityId, rootEpoch + 1)
|
): GroupKey = ConcordKeyDerivation.baseRekeyAddress(communityRoot, communityId, rootEpoch + 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base-rekey address the rotation INTO [entry]'s current epoch rode on, derived from the
|
||||||
|
* prior epoch's (canonical) held root — or null when we hold none (a fresh joiner at this
|
||||||
|
* epoch). Watching it after adopting is what lets the same-epoch race heal (CORD-06 §3): a
|
||||||
|
* racing sibling rotation sealed under the same prior root arrives here, and a strictly lower
|
||||||
|
* one replaces the root we adopted.
|
||||||
|
*/
|
||||||
|
fun siblingBaseRekeyPlane(entry: ConcordCommunityListEntry): GroupKey? {
|
||||||
|
if (entry.rootEpoch <= 0) return null
|
||||||
|
val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).firstOrNull { it.epoch == entry.rootEpoch - 1 } ?: return null
|
||||||
|
return ConcordKeyDerivation.baseRekeyAddress(prior.key.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- relay filters (what to REQ) -----------------------------------------
|
// ---- relay filters (what to REQ) -----------------------------------------
|
||||||
|
|
||||||
/** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */
|
/** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */
|
||||||
@@ -551,6 +565,8 @@ object ConcordActions {
|
|||||||
name: String,
|
name: String,
|
||||||
relays: List<String>,
|
relays: List<String>,
|
||||||
controlPk: HexKey? = null,
|
controlPk: HexKey? = null,
|
||||||
|
creator: HexKey? = null,
|
||||||
|
label: String? = null,
|
||||||
): CommunityInvite =
|
): CommunityInvite =
|
||||||
CommunityInvite(
|
CommunityInvite(
|
||||||
communityId = communityIdHex,
|
communityId = communityIdHex,
|
||||||
@@ -561,6 +577,10 @@ object ConcordActions {
|
|||||||
controlPk = controlPk,
|
controlPk = controlPk,
|
||||||
relays = relays,
|
relays = relays,
|
||||||
name = name,
|
name = name,
|
||||||
|
// Optional attribution (CORD-05 §1): echoed in the joiner's Guestbook Join, so link
|
||||||
|
// holders can count per-link usage. Inside the token-encrypted bundle only.
|
||||||
|
creatorNpub = creator,
|
||||||
|
label = label,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */
|
/** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */
|
||||||
@@ -620,15 +640,25 @@ object ConcordActions {
|
|||||||
fun bareInviteRef(url: String): String? = ConcordInviteLink.bareForm(url)
|
fun bareInviteRef(url: String): String? = ConcordInviteLink.bareForm(url)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges a stranded membership forward onto a higher-epoch [bundle] resolved at
|
* True when a live [bundle] resolved at [entry]'s own stored invite link says a Refounding
|
||||||
* its own stored invite link, or null when there is nothing to recover. See
|
* left us behind (a higher epoch, and we are not banned). Detection only: a bundle may never
|
||||||
* [ConcordStrandedRecovery].
|
* move a held community's base on its own (CORD-06 §2) — see [ConcordStrandedRecovery].
|
||||||
*/
|
*/
|
||||||
fun recoverStranded(
|
fun isStranded(
|
||||||
entry: ConcordCommunityListEntry,
|
entry: ConcordCommunityListEntry,
|
||||||
bundle: CommunityInvite,
|
bundle: CommunityInvite,
|
||||||
bannedAtCurrentEpoch: Boolean,
|
bannedAtCurrentEpoch: Boolean,
|
||||||
): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle, bannedAtCurrentEpoch)
|
): Boolean = ConcordStrandedRecovery.isStranded(entry, bundle, bannedAtCurrentEpoch)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry after the user **explicitly** re-accepts the invite link a stranded [entry] was
|
||||||
|
* joined through, or null when not stranded. Only ever from a user action — never a sweep.
|
||||||
|
*/
|
||||||
|
fun rejoinStranded(
|
||||||
|
entry: ConcordCommunityListEntry,
|
||||||
|
bundle: CommunityInvite,
|
||||||
|
bannedAtCurrentEpoch: Boolean,
|
||||||
|
): ConcordCommunityListEntry? = ConcordStrandedRecovery.rejoinForward(entry, bundle, bannedAtCurrentEpoch)
|
||||||
|
|
||||||
/** Decrypts + validates a fetched bundle event with the link token; null if invalid. */
|
/** Decrypts + validates a fetched bundle event with the link token; null if invalid. */
|
||||||
fun openBundle(
|
fun openBundle(
|
||||||
@@ -641,13 +671,15 @@ object ConcordActions {
|
|||||||
* [InviteBundleStatus] (live / expired / revoked / unreadable / absent) per CORD-05
|
* [InviteBundleStatus] (live / expired / revoked / unreadable / absent) per CORD-05
|
||||||
* §2, so a redeeming client honours a `vsk=9` revocation tombstone and an
|
* §2, so a redeeming client honours a `vsk=9` revocation tombstone and an
|
||||||
* `expires_at` in the past, and reports why a link can't be opened instead of
|
* `expires_at` in the past, and reports why a link can't be opened instead of
|
||||||
* retrying blindly. [nowMs] is unix milliseconds.
|
* retrying blindly. [nowMs] is unix milliseconds. Only events genuinely at the link's
|
||||||
|
* coordinate count — signed by [linkSignerPubKey], `d == ""` — never what a relay claims is.
|
||||||
*/
|
*/
|
||||||
fun classifyInvite(
|
fun classifyInvite(
|
||||||
wraps: List<Event>,
|
wraps: List<Event>,
|
||||||
|
linkSignerPubKey: HexKey,
|
||||||
token: ByteArray,
|
token: ByteArray,
|
||||||
nowMs: Long = TimeUtils.nowMillis(),
|
nowMs: Long = TimeUtils.nowMillis(),
|
||||||
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token, nowMs)
|
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, linkSignerPubKey, token, nowMs)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Control Plane keys described by a redeemed [invite] so the joiner can
|
* The Control Plane keys described by a redeemed [invite] so the joiner can
|
||||||
@@ -735,6 +767,8 @@ object ConcordActions {
|
|||||||
staffXOnly: Set<HexKey>,
|
staffXOnly: Set<HexKey>,
|
||||||
createdAt: Long,
|
createdAt: Long,
|
||||||
ownerPubKey: HexKey,
|
ownerPubKey: HexKey,
|
||||||
|
authority: AuthorityCitation? = null,
|
||||||
|
mustCarry: Map<String, Long> = emptyMap(),
|
||||||
): RefoundingBuild =
|
): RefoundingBuild =
|
||||||
ConcordRefounding.build(
|
ConcordRefounding.build(
|
||||||
rotatorSigner = rotatorSigner,
|
rotatorSigner = rotatorSigner,
|
||||||
@@ -749,6 +783,8 @@ object ConcordActions {
|
|||||||
staffXOnly = staffXOnly,
|
staffXOnly = staffXOnly,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
ownerPubKey = ownerPubKey,
|
ownerPubKey = ownerPubKey,
|
||||||
|
authority = authority,
|
||||||
|
mustCarry = mustCarry,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -757,7 +793,9 @@ object ConcordActions {
|
|||||||
* scope, epoch and continuity against the [priorRoot] the member holds — and,
|
* scope, epoch and continuity against the [priorRoot] the member holds — and,
|
||||||
* on a staff blob, that the delivered `control_root` derives to the delivered
|
* 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
|
* `control_pk` (CORD-06 §1). Returns the new root + Control keys + rotator
|
||||||
* (for the caller to authorize) or null if not re-keyed.
|
* or null if not re-keyed. [accept] is the caller's authority check (see
|
||||||
|
* [ConcordReceive.isHonoredRotation]); racing rotations it admits converge on
|
||||||
|
* the lowest new root (CORD-06 §3).
|
||||||
*/
|
*/
|
||||||
suspend fun openBaseRekey(
|
suspend fun openBaseRekey(
|
||||||
wraps: List<Event>,
|
wraps: List<Event>,
|
||||||
@@ -766,5 +804,6 @@ object ConcordActions {
|
|||||||
communityId: HexKey,
|
communityId: HexKey,
|
||||||
priorRoot: ByteArray,
|
priorRoot: ByteArray,
|
||||||
rootEpoch: Long,
|
rootEpoch: Long,
|
||||||
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch)
|
accept: (ReceivedRefounding) -> Boolean = { true },
|
||||||
|
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch, accept)
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.actions
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||||
@@ -29,6 +30,9 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
|||||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
|
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
|
||||||
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
|
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRotationAuthority
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
|
||||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
@@ -106,6 +110,71 @@ object ConcordReceive {
|
|||||||
rotator: HexKey,
|
rotator: HexKey,
|
||||||
): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN)
|
): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a received base rotation may be adopted (CORD-06 §3 "Authority"): its rotator holds
|
||||||
|
* BAN (or is the owner) in our fold, AND it cites the Grant it acts under (`vac`) at a version
|
||||||
|
* our fold has synced — so a just-demoted admin's rotation is never honored by a client that
|
||||||
|
* lags the demotion, and a rotation citing a Grant we have not seen yet waits for it. The owner
|
||||||
|
* cites nothing. [editions] are the current epoch's Control editions the citation is checked
|
||||||
|
* against.
|
||||||
|
*/
|
||||||
|
fun isHonoredRotation(
|
||||||
|
entry: ConcordCommunityListEntry,
|
||||||
|
editions: Collection<ControlEdition>,
|
||||||
|
authority: AuthorityResolver,
|
||||||
|
received: ReceivedRefounding,
|
||||||
|
): Boolean {
|
||||||
|
if (!isAuthorizedRotator(authority, received.rotator)) return false
|
||||||
|
val heads = ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner)
|
||||||
|
return ConcordRotationAuthority.citationSatisfied(entry.id, received.rotator, entry.owner, received.authority, heads)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `vac` citation [actor] stamps on a rotation it launches (CORD-06 §3): their own Grant's
|
||||||
|
* head in our fold, or null for the owner (who cites nothing).
|
||||||
|
*/
|
||||||
|
fun rotationCitation(
|
||||||
|
entry: ConcordCommunityListEntry,
|
||||||
|
editions: Collection<ControlEdition>,
|
||||||
|
actor: HexKey,
|
||||||
|
): AuthorityCitation? = ConcordRotationAuthority.citationFor(entry.id, actor, entry.owner, ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The down-only same-epoch heal (CORD-06 §3): [entry] holds a root at its current epoch, and
|
||||||
|
* [sibling] is a rotation to that same epoch (from the same prior root) that we did not adopt.
|
||||||
|
* Returns the entry moved onto the sibling when its root is **strictly lower** — the losing
|
||||||
|
* (higher) root we held is kept as a held root of the same epoch, so the messages sent into
|
||||||
|
* that fork stay readable — or null when the sibling does not win.
|
||||||
|
*
|
||||||
|
* The losing root keeps no control material: its Control Plane is the losing fork's
|
||||||
|
* compaction, not the community's ([ConcordRefounding.canonicalHeldRoots] never folds it).
|
||||||
|
*/
|
||||||
|
fun withHealedRoot(
|
||||||
|
entry: ConcordCommunityListEntry,
|
||||||
|
sibling: ReceivedRefounding,
|
||||||
|
): ConcordCommunityListEntry? {
|
||||||
|
if (sibling.newEpoch != entry.rootEpoch) return null
|
||||||
|
if (!ConcordRefounding.healsTo(entry.root.hexToByteArray(), sibling.newRoot)) return null
|
||||||
|
return ConcordCommunityListEntry(
|
||||||
|
id = entry.id,
|
||||||
|
owner = entry.owner,
|
||||||
|
ownerSalt = entry.ownerSalt,
|
||||||
|
root = sibling.newRoot.toHexKey(),
|
||||||
|
rootEpoch = entry.rootEpoch,
|
||||||
|
// The control pair is the winner's blob's, never inherited from the losing fork.
|
||||||
|
controlPk = sibling.newControlPk?.toHexKey(),
|
||||||
|
controlRoot = sibling.newControlRoot?.toHexKey(),
|
||||||
|
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch to it.key.lowercase() },
|
||||||
|
privateChannels = entry.privateChannels,
|
||||||
|
relays = entry.relays,
|
||||||
|
name = entry.name,
|
||||||
|
addedAt = entry.addedAt,
|
||||||
|
inviteRef = entry.inviteRef,
|
||||||
|
excludedAtEpoch = entry.excludedAtEpoch,
|
||||||
|
residue = entry.residue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The entry that results from adopting a base rotation to [newEpoch] — a pure rewrite, so the
|
* The entry that results from adopting a base rotation to [newEpoch] — a pure rewrite, so the
|
||||||
* caller can diff, persist and publish it however its platform does.
|
* caller can diff, persist and publish it however its platform does.
|
||||||
@@ -133,7 +202,9 @@ object ConcordReceive {
|
|||||||
rootEpoch = newEpoch,
|
rootEpoch = newEpoch,
|
||||||
controlPk = newControlPk?.toHexKey(),
|
controlPk = newControlPk?.toHexKey(),
|
||||||
controlRoot = newControlRoot?.toHexKey(),
|
controlRoot = newControlRoot?.toHexKey(),
|
||||||
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch },
|
// Keyed by (epoch, key), not epoch alone: a healed race leaves a losing fork's root at the
|
||||||
|
// same epoch, kept so its messages stay readable (CORD-06 §3).
|
||||||
|
heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch to it.key.lowercase() },
|
||||||
privateChannels = entry.privateChannels,
|
privateChannels = entry.privateChannels,
|
||||||
relays = entry.relays,
|
relays = entry.relays,
|
||||||
name = entry.name,
|
name = entry.name,
|
||||||
|
|||||||
+8
-2
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
|
|||||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
@@ -78,7 +79,9 @@ object ConcordSubscriptionPlanner {
|
|||||||
// the entry, per epoch, exactly as it was delivered.
|
// the entry, per epoch, exactly as it was delivered.
|
||||||
val cp = ConcordActions.controlPlaneKeysFor(e)
|
val cp = ConcordActions.controlPlaneKeysFor(e)
|
||||||
val historical =
|
val historical =
|
||||||
e.heldRoots
|
// Losing-fork roots of a healed race are kept for their messages only (CORD-06 §3).
|
||||||
|
ConcordRefounding
|
||||||
|
.canonicalHeldRoots(e.heldRoots)
|
||||||
.filter { it.epoch < e.rootEpoch }
|
.filter { it.epoch < e.rootEpoch }
|
||||||
.sortedByDescending { it.epoch }
|
.sortedByDescending { it.epoch }
|
||||||
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
|
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
|
||||||
@@ -104,10 +107,13 @@ object ConcordSubscriptionPlanner {
|
|||||||
val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch)
|
val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch)
|
||||||
val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch)
|
val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch)
|
||||||
val dissolved = ConcordDissolution.planeKey(e.id)
|
val dissolved = ConcordDissolution.planeKey(e.id)
|
||||||
listOf(
|
val sibling = ConcordActions.siblingBaseRekeyPlane(e)
|
||||||
|
listOfNotNull(
|
||||||
ConcordPlaneSub(channelId = null, pubKeyHex = guestbook.publicKeyHex, relays = relays),
|
ConcordPlaneSub(channelId = null, pubKeyHex = guestbook.publicKeyHex, relays = relays),
|
||||||
ConcordPlaneSub(channelId = null, pubKeyHex = nextRekey.publicKeyHex, relays = relays),
|
ConcordPlaneSub(channelId = null, pubKeyHex = nextRekey.publicKeyHex, relays = relays),
|
||||||
ConcordPlaneSub(channelId = null, pubKeyHex = dissolved.publicKeyHex, relays = relays),
|
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) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+272
-110
@@ -58,6 +58,10 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
|
|||||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
|
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
|
||||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
|
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
|
||||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||||
@@ -65,7 +69,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PagedFetchResult
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||||
@@ -83,6 +89,9 @@ import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
|
|||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
/** Name of the default Concord community Admin role minted by "Make admin". */
|
/** Name of the default Concord community Admin role minted by "Make admin". */
|
||||||
private const val CONCORD_ADMIN_ROLE = "Admin"
|
private const val CONCORD_ADMIN_ROLE = "Admin"
|
||||||
@@ -104,6 +113,9 @@ private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
|
|||||||
*/
|
*/
|
||||||
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
|
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
|
||||||
|
|
||||||
|
/** A lowercase 32-byte hex key (the Guestbook `invite` tag's creator). */
|
||||||
|
private val HEX64 = Regex("^[0-9a-f]{64}$")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Concord (encrypted communities) orchestration for an [Account]: join/create/
|
* Concord (encrypted communities) orchestration for an [Account]: join/create/
|
||||||
* invite flows, channel messages/reactions/edits/typing, roles and moderation,
|
* invite flows, channel messages/reactions/edits/typing, roles and moderation,
|
||||||
@@ -332,7 +344,7 @@ class AccountConcordActions(
|
|||||||
val token = link.token.hexToByteArray()
|
val token = link.token.hexToByteArray()
|
||||||
// Classify per coordinate, never over the pooled set: one link's newer
|
// Classify per coordinate, never over the pooled set: one link's newer
|
||||||
// revocation tombstone must not decide another link's status.
|
// revocation tombstone must not decide another link's status.
|
||||||
val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), token) as? InviteBundleStatus.Live ?: return@runCatching false
|
val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), author, token) as? InviteBundleStatus.Live ?: return@runCatching false
|
||||||
val moved =
|
val moved =
|
||||||
current.invite.copy(
|
current.invite.copy(
|
||||||
communityRoot = entry.root,
|
communityRoot = entry.root,
|
||||||
@@ -390,7 +402,11 @@ class AccountConcordActions(
|
|||||||
// The joiner can never derive the Control Plane address, so the bundle carries
|
// 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.
|
// it (CORD-05 §1). Null on a legacy community, which has none to carry.
|
||||||
controlPk = entry.controlPk,
|
controlPk = entry.controlPk,
|
||||||
|
// Attribution the joiner echoes in their Guestbook Join (CORD-05 §1).
|
||||||
|
creator = account.signer.pubKey,
|
||||||
)
|
)
|
||||||
|
// The fragment carries at most 3 bootstrap relays (CORD-05 §3); the codec truncates a longer
|
||||||
|
// list (the stock set stays a single flag), and the bundle keeps the full relay set.
|
||||||
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays)
|
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays)
|
||||||
|
|
||||||
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
|
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
|
||||||
@@ -535,7 +551,7 @@ class AccountConcordActions(
|
|||||||
// stale openable copy) so we honour revocation and can tell the user *why* a link won't open
|
// stale openable copy) so we honour revocation and can tell the user *why* a link won't open
|
||||||
// instead of stranding them on a spinner that retries a link we can never redeem.
|
// instead of stranding them on a spinner that retries a link we can never redeem.
|
||||||
val bundle =
|
val bundle =
|
||||||
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
|
when (val status = ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token)) {
|
||||||
is InviteBundleStatus.Live -> status.invite
|
is InviteBundleStatus.Live -> status.invite
|
||||||
is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired
|
is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired
|
||||||
InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked
|
InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked
|
||||||
@@ -547,10 +563,25 @@ class AccountConcordActions(
|
|||||||
// Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an
|
// Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an
|
||||||
// old invite is reopened, so short-circuit to Joined — the screen forwards to the community
|
// old invite is reopened, so short-circuit to Joined — the screen forwards to the community
|
||||||
// either way ("take me there", not "join again").
|
// either way ("take me there", not "join again").
|
||||||
if (account.concordChannelList.liveCommunities.value
|
//
|
||||||
.any { it.id == bundle.communityId }
|
// The one exception is a membership a Refounding left behind: the background sweep only
|
||||||
) {
|
// DETECTS that (a bundle may never move a held community's base on its own, CORD-06 §2), so
|
||||||
return ConcordInviteResult.Joined(bundle.communityId)
|
// the user explicitly re-accepting the link is the way forward — the same trust decision as
|
||||||
|
// their first join, taken by them.
|
||||||
|
val held =
|
||||||
|
account.concordChannelList.liveCommunities.value
|
||||||
|
.firstOrNull { it.id == bundle.communityId }
|
||||||
|
var rejoined: ConcordCommunityListEntry? = null
|
||||||
|
if (held != null) {
|
||||||
|
val heldState =
|
||||||
|
account.concordSessions
|
||||||
|
.sessionFor(held.id)
|
||||||
|
?.state
|
||||||
|
?.value
|
||||||
|
// Death wins every race (CORD-02 §9): nothing moves a dissolved community forward.
|
||||||
|
if (heldState == null || heldState.dissolved) return ConcordInviteResult.Joined(bundle.communityId)
|
||||||
|
rejoined = ConcordActions.rejoinStranded(held, bundle, heldState.authority.isBanned(account.signer.pubKey))
|
||||||
|
?: return ConcordInviteResult.Joined(bundle.communityId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
|
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
|
||||||
@@ -589,6 +620,19 @@ class AccountConcordActions(
|
|||||||
return ConcordInviteResult.Banned
|
return ConcordInviteResult.Banned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invite attribution (CORD-05 §1): the joiner echoes the link's creator + label in their
|
||||||
|
// Guestbook Join, which is what makes per-link usage counters possible.
|
||||||
|
val inviteCreator = bundle.creatorNpub?.lowercase()?.takeIf { HEX64.matches(it) }
|
||||||
|
val inviteLabel = bundle.label?.takeIf { inviteCreator != null && it.isNotBlank() }
|
||||||
|
|
||||||
|
if (rejoined != null) {
|
||||||
|
if (!adoptedConcordRotations.add("${rejoined.id}:${rejoined.rootEpoch}")) return ConcordInviteResult.Joined(bundle.communityId)
|
||||||
|
Log.i("Concord") { "Stranded rejoin by explicit invite: ${rejoined.id} -> epoch ${rejoined.rootEpoch}" }
|
||||||
|
joinConcordCommunity(rejoined, inviteCreator, inviteLabel)
|
||||||
|
_strandedConcordCommunities.value -= rejoined.id
|
||||||
|
return ConcordInviteResult.Joined(bundle.communityId)
|
||||||
|
}
|
||||||
|
|
||||||
val entry =
|
val entry =
|
||||||
ConcordCommunityListEntry(
|
ConcordCommunityListEntry(
|
||||||
id = bundle.communityId,
|
id = bundle.communityId,
|
||||||
@@ -610,7 +654,7 @@ class AccountConcordActions(
|
|||||||
// recoverStrandedConcordCommunities().
|
// recoverStrandedConcordCommunities().
|
||||||
inviteRef = ConcordActions.bareInviteRef(url),
|
inviteRef = ConcordActions.bareInviteRef(url),
|
||||||
)
|
)
|
||||||
joinConcordCommunity(entry)
|
joinConcordCommunity(entry, inviteCreator, inviteLabel)
|
||||||
return ConcordInviteResult.Joined(bundle.communityId)
|
return ConcordInviteResult.Joined(bundle.communityId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1200,6 +1244,12 @@ class AccountConcordActions(
|
|||||||
if (!account.isWriteable()) return false
|
if (!account.isWriteable()) return false
|
||||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||||
val state = session.state.value ?: return false
|
val state = session.state.value ?: return false
|
||||||
|
// Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored, so a
|
||||||
|
// Refounding of a dissolved community would only strand whoever follows it.
|
||||||
|
if (state.dissolved) {
|
||||||
|
Log.w("Concord") { "Refusing to refound ${session.entry.id}: the community was dissolved (CORD-02 §9)" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
val authority = state.authority
|
val authority = state.authority
|
||||||
// hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol
|
// hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol
|
||||||
// and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the
|
// and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the
|
||||||
@@ -1221,6 +1271,19 @@ class AccountConcordActions(
|
|||||||
// compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A
|
// 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.
|
// rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery.
|
||||||
val cp = controlKeysForWrite(session) ?: return false
|
val cp = controlKeysForWrite(session) ?: return false
|
||||||
|
val entry = session.entry
|
||||||
|
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||||
|
if (publishTo.isEmpty()) return false
|
||||||
|
|
||||||
|
// 0. Acquire the whole Control Plane BEFORE the first publish (CORD-06 §3: "If the Refounder
|
||||||
|
// cannot reliably fold all Control events, the Refounding must be aborted"). The live
|
||||||
|
// buffer is whatever the subscription happened to deliver; a paged sweep that a majority
|
||||||
|
// of the community's relays drained is what makes the compaction the whole plane.
|
||||||
|
val swept = sweepConcordControlPlane(cp.address, publishTo)
|
||||||
|
if (swept == null) {
|
||||||
|
Log.w("Concord") { "Refounding ${entry.id} aborted: too few relays served the whole Control Plane" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Ban the removed members on the current Control Plane so the compacted snapshot —
|
// 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
|
// and thus the new epoch — carries the ban. publishConcordWrap folds it in locally
|
||||||
@@ -1240,7 +1303,7 @@ class AccountConcordActions(
|
|||||||
//
|
//
|
||||||
// Still a floor, not a census (see allMembers): a member who joined without a Guestbook
|
// Still a floor, not a census (see allMembers): a member who joined without a Guestbook
|
||||||
// motion, holds no role, and has never posted leaves no trace to find, so a Refounding
|
// motion, holds no role, and has never posted leaves no trace to find, so a Refounding
|
||||||
// cannot re-key them. Stranded recovery is what gets those members back.
|
// cannot re-key them.
|
||||||
val recipients =
|
val recipients =
|
||||||
(session.allMembers() + account.signer.pubKey)
|
(session.allMembers() + account.signer.pubKey)
|
||||||
.mapTo(HashSet()) { it.lowercase() }
|
.mapTo(HashSet()) { it.lowercase() }
|
||||||
@@ -1250,52 +1313,113 @@ class AccountConcordActions(
|
|||||||
}.let { candidates -> boundRecipients(candidates, authority) }
|
}.let { candidates -> boundRecipients(candidates, authority) }
|
||||||
|
|
||||||
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
|
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
|
||||||
val entry = session.entry
|
// The keys are RESERVED per (epoch, prior root): a retry after a failed publish re-delivers
|
||||||
val newRoot = RandomInstance.bytes(32)
|
// the same root instead of minting a sibling that would split the members (CORD-06 §3).
|
||||||
// A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2),
|
// A fresh control_root rides beside the new root at every Refounding (CORD-02 §2), so a
|
||||||
// so a demoted staffer's retained secret dies with the epoch — and a legacy community
|
// demoted staffer's retained secret dies with the epoch — and a legacy community upgrades
|
||||||
// upgrades to the split as a side effect of its next ban (CORD-06 §3).
|
// to the split as a side effect of its next ban (CORD-06 §3).
|
||||||
val newControlRoot = RandomInstance.bytes(32)
|
val priorRoot = entry.root.hexToByteArray()
|
||||||
|
val keys = ConcordRefounding.reserveKeys(pendingConcordRefoundings[entry.id], entry.id, entry.rootEpoch, priorRoot)
|
||||||
|
pendingConcordRefoundings[entry.id] = keys
|
||||||
|
val editions = session.controlEditions()
|
||||||
|
// The rotation cites the Grant it acts under (CORD-06 §3 "Authority"); the owner cites none.
|
||||||
|
// A non-owner with no Grant in our own fold has nothing to cite, so no receiver would honor it.
|
||||||
|
val citation = ConcordReceive.rotationCitation(entry, editions, account.signer.pubKey)
|
||||||
|
if (citation == null && !authority.isOwner(account.signer.pubKey)) {
|
||||||
|
Log.w("Concord") { "Refounding ${entry.id} aborted: no Grant of ours to cite (CORD-06 §3)" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
// The staff set the new secret goes to: the owner plus everyone holding a
|
// 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
|
// 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
|
// 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.)
|
// blob per recipient, so staff who aren't recipients are simply never reached.)
|
||||||
val staff = authority.staffMembers()
|
val staff = authority.staffMembers()
|
||||||
val build =
|
val build =
|
||||||
ConcordActions.buildRefounding(
|
try {
|
||||||
rotatorSigner = account.signer,
|
ConcordActions.buildRefounding(
|
||||||
communityId = communityId,
|
rotatorSigner = account.signer,
|
||||||
priorRoot = entry.root.hexToByteArray(),
|
communityId = communityId,
|
||||||
newRoot = newRoot,
|
priorRoot = priorRoot,
|
||||||
newControlRoot = newControlRoot,
|
newRoot = keys.newRoot,
|
||||||
rootEpoch = entry.rootEpoch,
|
newControlRoot = keys.newControlRoot,
|
||||||
priorControlWraps = session.controlPlaneWraps(),
|
rootEpoch = entry.rootEpoch,
|
||||||
priorControlKeys = cp,
|
priorControlWraps = (session.controlPlaneWraps() + swept).distinctBy { it.id },
|
||||||
recipientsXOnly = recipients,
|
priorControlKeys = cp,
|
||||||
staffXOnly = staff,
|
recipientsXOnly = recipients,
|
||||||
createdAt = TimeUtils.now(),
|
staffXOnly = staff,
|
||||||
ownerPubKey = entry.owner,
|
createdAt = TimeUtils.now(),
|
||||||
)
|
ownerPubKey = entry.owner,
|
||||||
|
authority = citation,
|
||||||
|
// Every head our own fold honors must survive into the new epoch.
|
||||||
|
mustCarry = ConcordRefounding.headVersions(editions, entry.id.hexToByteArray(), entry.owner),
|
||||||
|
)
|
||||||
|
} catch (e: IncompleteControlPlaneException) {
|
||||||
|
Log.w("Concord", "Refounding ${entry.id} aborted: the Control Plane could not be folded in full", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs
|
// 4. The root roll FIRST, each chunk confirmed (CORD-06 §3): the compacted plane is
|
||||||
// (the key that unlocks it) to the community relays.
|
// republished only after the rekey blobs are known to have landed. A chunk no relay
|
||||||
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
// accepted aborts here with nothing adopted; the reserved keys make the retry idempotent.
|
||||||
if (publishTo.isNotEmpty()) {
|
for (wrap in build.rekeyWraps) {
|
||||||
build.controlWraps.forEach { account.client.publish(it, publishTo) }
|
if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) {
|
||||||
build.rekeyWraps.forEach { account.client.publish(it, publishTo) }
|
Log.w("Concord") { "Refounding ${entry.id} aborted: a rekey chunk was not accepted by any relay; retrying reuses the same root" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and
|
// 5. The compacted Control Plane, at the new epoch's address. The root roll is committed, so
|
||||||
// re-folds the compacted Control Plane (with the ban), dropping the removed members.
|
// a head that fails to land is reported, not rolled back — members already hold the new
|
||||||
val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
|
// root, and the next Refounding re-compacts from the same signed heads.
|
||||||
|
var compactionLanded = true
|
||||||
|
for (wrap in build.controlWraps) {
|
||||||
|
if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) compactionLanded = false
|
||||||
|
}
|
||||||
|
if (!compactionLanded) Log.w("Concord") { "Refounding ${entry.id}: some compacted Control Plane heads were not accepted at epoch ${build.newEpoch}" }
|
||||||
|
|
||||||
// 6. Move every link we minted to the new epoch. Without this the Refounding orphans them,
|
// 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)
|
||||||
|
pendingConcordRefoundings.remove(entry.id)
|
||||||
|
|
||||||
|
// 7. Move every link we minted to the new epoch. Without this the Refounding orphans them,
|
||||||
// and a member it left out — no rekey blob, no message to miss — has no way back at all.
|
// and a member it left out — no rekey blob, no message to miss — has no way back at all.
|
||||||
// Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so
|
// Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so
|
||||||
// reading it here would hand us the epoch we just left and re-mint every link onto it.
|
// reading it here would hand us the epoch we just left and re-mint every link onto it.
|
||||||
val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0
|
val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0
|
||||||
Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" }
|
Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" }
|
||||||
return true
|
return compactionLanded
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
private val pendingConcordRefoundings = ConcurrentMap<String, PendingRefounding>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages the whole Control Plane at [address] off every relay in [relays], or null when fewer
|
||||||
|
* than a majority of them drained it (a dead relay must not block rotation forever, but too few
|
||||||
|
* would compact a partial plane and roll the community back for everyone who follows).
|
||||||
|
*/
|
||||||
|
private suspend fun sweepConcordControlPlane(
|
||||||
|
address: HexKey,
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
): List<Event>? {
|
||||||
|
val filter = ConcordActions.planeFilter(address)
|
||||||
|
val perRelay =
|
||||||
|
coroutineScope {
|
||||||
|
relays
|
||||||
|
.map { relay ->
|
||||||
|
async {
|
||||||
|
val events = ArrayList<Event>()
|
||||||
|
val result = runCatching { account.client.fetchAllPages(relay, listOf(filter)) { events.add(it) } }.getOrNull()
|
||||||
|
if (result?.end == PagedFetchResult.End.DRAINED) events else null
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
val drained = perRelay.filterNotNull()
|
||||||
|
if (drained.size < relays.size / 2 + 1) return null
|
||||||
|
return drained.flatten().distinctBy { it.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1392,43 +1516,79 @@ class AccountConcordActions(
|
|||||||
*
|
*
|
||||||
* A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list,
|
* A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list,
|
||||||
* so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the
|
* so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the
|
||||||
* owner included) by omission — nothing on this receive path can prevent it. The
|
* owner included) by omission — nothing on this receive path can prevent it.
|
||||||
* cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves
|
* [recoverStrandedConcordCommunities] detects it; re-opening the invite link rejoins.
|
||||||
* the invite link the membership was joined through and merges forward.
|
*
|
||||||
|
* Also runs the same-epoch race heal (CORD-06 §3): racing rotations converge on the
|
||||||
|
* lowest authorized root, and a sibling seen after we adopted replaces our root only
|
||||||
|
* when strictly lower. Nothing is adopted for a dissolved community (CORD-02 §9).
|
||||||
*/
|
*/
|
||||||
internal suspend fun drainConcordRekeys() {
|
internal suspend fun drainConcordRekeys() {
|
||||||
if (!account.isWriteable()) return
|
if (!account.isWriteable()) return
|
||||||
for (session in account.concordSessions.sessions()) {
|
for (session in account.concordSessions.sessions()) {
|
||||||
val wraps = session.pendingBaseRekeyWraps()
|
|
||||||
if (wraps.isEmpty()) continue
|
|
||||||
val entry = session.entry
|
val entry = session.entry
|
||||||
val received =
|
val state = session.state.value ?: continue
|
||||||
|
// Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored.
|
||||||
|
if (state.dissolved) continue
|
||||||
|
val authority = state.authority
|
||||||
|
val editions = session.controlEditions()
|
||||||
|
// Authority is the roster plus the cited Grant, never key possession (CORD-06 §3):
|
||||||
|
// hasPermission (not effectivePermissions, which ignores the banlist) and a `vac` our
|
||||||
|
// fold has synced, so a just-demoted admin's rotation is not honored while we lag.
|
||||||
|
val honored = { r: ReceivedRefounding -> ConcordReceive.isHonoredRotation(entry, editions, authority, r) }
|
||||||
|
|
||||||
|
val wraps = session.pendingBaseRekeyWraps()
|
||||||
|
if (wraps.isNotEmpty()) {
|
||||||
|
// Racing authorized rotations converge on the lowest new root (CORD-06 §3).
|
||||||
|
val received =
|
||||||
|
ConcordActions.openBaseRekey(
|
||||||
|
wraps = wraps,
|
||||||
|
baseRekey = session.nextBaseRekeyKey(),
|
||||||
|
recipientSigner = account.signer,
|
||||||
|
communityId = entry.id,
|
||||||
|
priorRoot = entry.root.hexToByteArray(),
|
||||||
|
rootEpoch = entry.rootEpoch,
|
||||||
|
accept = honored,
|
||||||
|
)
|
||||||
|
if (received != null && received.newEpoch > entry.rootEpoch) {
|
||||||
|
val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
||||||
|
// A rotation we did not launch superseded the one we may have had in flight.
|
||||||
|
pendingConcordRefoundings.remove(entry.id)
|
||||||
|
// Move our own links onto the epoch we just adopted. Rotating is not the only way
|
||||||
|
// to end up on a new epoch — being re-keyed is the common one — and a link creator
|
||||||
|
// who is merely re-keyed would otherwise leave every link they handed out pointing
|
||||||
|
// at the dead root.
|
||||||
|
adopted?.let { next ->
|
||||||
|
val moved = refreshConcordInviteLinks(next)
|
||||||
|
if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" }
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same-epoch heal (CORD-06 §3): a racing rotation into the epoch we hold, sealed under
|
||||||
|
// the same prior root, wins only when its root is strictly lower. Our losing root stays
|
||||||
|
// held so the messages sent into that fork stay readable.
|
||||||
|
val siblingKey = session.siblingBaseRekeyKey() ?: continue
|
||||||
|
val siblingWraps = session.pendingSiblingRekeyWraps()
|
||||||
|
if (siblingWraps.isEmpty()) continue
|
||||||
|
val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).firstOrNull { it.epoch == entry.rootEpoch - 1 } ?: continue
|
||||||
|
val sibling =
|
||||||
ConcordActions.openBaseRekey(
|
ConcordActions.openBaseRekey(
|
||||||
wraps = wraps,
|
wraps = siblingWraps,
|
||||||
baseRekey = session.nextBaseRekeyKey(),
|
baseRekey = siblingKey,
|
||||||
recipientSigner = account.signer,
|
recipientSigner = account.signer,
|
||||||
communityId = entry.id,
|
communityId = entry.id,
|
||||||
priorRoot = entry.root.hexToByteArray(),
|
priorRoot = prior.key.hexToByteArray(),
|
||||||
rootEpoch = entry.rootEpoch,
|
rootEpoch = prior.epoch,
|
||||||
|
accept = honored,
|
||||||
) ?: continue
|
) ?: continue
|
||||||
if (received.newEpoch <= entry.rootEpoch) continue
|
val healed = ConcordReceive.withHealedRoot(entry, sibling) ?: continue
|
||||||
val authority = session.state.value?.authority ?: continue
|
if (!adoptedConcordRotations.add("${healed.id}:${healed.rootEpoch}:${healed.root}")) continue
|
||||||
|
Log.i("Concord") { "Rekey race ${entry.id}: epoch ${entry.rootEpoch} converged on the lower sibling root" }
|
||||||
// hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder
|
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(healed))
|
||||||
// who has themselves been banned could still rotate the whole community.
|
announceConcordGuestbookJoin(healed, inviteCreator = null, inviteLabel = null)
|
||||||
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
|
refreshConcordInviteLinks(healed)
|
||||||
if (!authorized) continue
|
|
||||||
val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
|
||||||
|
|
||||||
// Move our own links onto the epoch we just adopted. Rotating is not the only way to end
|
|
||||||
// up on a new epoch — being re-keyed is the common one — and a link creator who is merely
|
|
||||||
// re-keyed would otherwise leave every link they handed out pointing at the dead root,
|
|
||||||
// which is exactly the orphaning this branch exists to stop. Stranded recovery reads the
|
|
||||||
// bundle's epoch, so a link nobody re-mints is a member nobody can recover.
|
|
||||||
adopted?.let { next ->
|
|
||||||
val moved = refreshConcordInviteLinks(next)
|
|
||||||
if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1462,6 +1622,14 @@ class AccountConcordActions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _strandedConcordCommunities = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Communities whose own invite link resolves to a higher epoch than we hold — a Refounding left
|
||||||
|
* us behind (see [recoverStrandedConcordCommunities]). Re-opening that link rejoins them.
|
||||||
|
*/
|
||||||
|
val strandedConcordCommunities: StateFlow<Set<String>> = _strandedConcordCommunities.asStateFlow()
|
||||||
|
|
||||||
// Last time we re-resolved each community's invite_ref, so the recovery sweep rides the
|
// Last time we re-resolved each community's invite_ref, so the recovery sweep rides the
|
||||||
// Concord revision tick (which fires on every structural change) without turning it into a
|
// Concord revision tick (which fires on every structural change) without turning it into a
|
||||||
// relay-fetch loop.
|
// relay-fetch loop.
|
||||||
@@ -1475,20 +1643,18 @@ class AccountConcordActions(
|
|||||||
* included, and [drainConcordRekeys] cannot prevent it: there is no message to
|
* included, and [drainConcordRekeys] cannot prevent it: there is no message to
|
||||||
* miss detecting.
|
* miss detecting.
|
||||||
*
|
*
|
||||||
* The way back is the invite link the membership was joined through
|
* The signal is the invite link the membership was joined through
|
||||||
* ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and
|
* ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and
|
||||||
* carried through every rotation by [adoptConcordRoot]). The community keeps
|
* carried through every rotation by [adoptConcordRoot]). The community keeps
|
||||||
* re-minting its bundle at that same addressable coordinate, so a bundle there at
|
* re-minting its bundle at that same addressable coordinate, so a bundle there at
|
||||||
* a **strictly higher** epoch than ours proves we were left behind — and carries
|
* a **strictly higher** epoch than ours says we were left behind. Memberships with
|
||||||
* the new root. Same or lower epoch is a no-op. Memberships with no link (direct
|
* no link (direct invites, legacy entries) are inert here.
|
||||||
* invites, legacy entries) are inert here; that is expected, not an error.
|
|
||||||
*
|
*
|
||||||
* The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps
|
* Detection ONLY ([strandedConcordCommunities]). The bundle is not proof of
|
||||||
* both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the
|
* continuity — nothing binds `community_root` to `community_id` — so adopting its
|
||||||
* entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays
|
* root here would let any link creator relocate every member who joined through
|
||||||
* derivable). We then re-announce the Guestbook at the new epoch, exactly as an
|
* their link (CORD-06 §2: the base advances only by a verifiable rekey). The way
|
||||||
* ordinary rotation does, so the recovered member is visible to whoever refounds
|
* forward is the user explicitly re-opening the link ([joinConcordViaInvite]).
|
||||||
* next instead of being silently dropped again.
|
|
||||||
*
|
*
|
||||||
* Called on the Concord revision tick, but rate-limited per community
|
* Called on the Concord revision tick, but rate-limited per community
|
||||||
* ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup.
|
* ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup.
|
||||||
@@ -1502,6 +1668,21 @@ class AccountConcordActions(
|
|||||||
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
|
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
|
||||||
lastConcordRecoveryCheck[entry.id] = now
|
lastConcordRecoveryCheck[entry.id] = now
|
||||||
|
|
||||||
|
// Fails CLOSED: no fold, no verdict — a banned member's cold-start window must not read
|
||||||
|
// as "not banned". Retried on the next sweep once the roster is known.
|
||||||
|
val state =
|
||||||
|
account.concordSessions
|
||||||
|
.sessionFor(entry.id)
|
||||||
|
?.state
|
||||||
|
?.value
|
||||||
|
if (state == null) {
|
||||||
|
Log.i("Concord") { "Stranded check deferred for ${entry.id}: control plane not folded yet" }
|
||||||
|
lastConcordRecoveryCheck.remove(entry.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Death wins every race (CORD-02 §9): a dissolved community is never "behind".
|
||||||
|
if (state.dissolved) continue
|
||||||
|
|
||||||
val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue
|
val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue
|
||||||
val relays =
|
val relays =
|
||||||
(
|
(
|
||||||
@@ -1512,39 +1693,14 @@ class AccountConcordActions(
|
|||||||
|
|
||||||
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
|
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
|
||||||
val wraps = account.client.fetchAll(filters = filters)
|
val wraps = account.client.fetchAll(filters = filters)
|
||||||
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
|
// Only a live bundle counts: an expired/revoked link is not a rotation we missed.
|
||||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
|
val bundle = (ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
|
||||||
|
|
||||||
// A removed member holds the link's unlock token forever, so without this the sweep
|
// Detection only (CORD-06 §2): the bundle is not proof of continuity, so it never moves
|
||||||
// walks them straight back into the epoch they were rotated out of — see A2 in
|
// our base. The user re-accepting the link is the way forward (joinConcordViaInvite).
|
||||||
// docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last
|
val stranded = ConcordActions.isStranded(entry, bundle, state.authority.isBanned(account.signer.pubKey))
|
||||||
// one whose Control Plane we can still fold.
|
_strandedConcordCommunities.value = if (stranded) _strandedConcordCommunities.value + entry.id else _strandedConcordCommunities.value - entry.id
|
||||||
//
|
if (stranded) Log.i("Concord") { "Stranded: ${entry.id} is at epoch ${entry.rootEpoch}, its invite link at ${bundle.rootEpoch}; re-open the link to rejoin" }
|
||||||
// Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not
|
|
||||||
// exist yet or whose first fold has not landed, and this sweep runs on the revision tick
|
|
||||||
// — so a banned member's own client would have hit that window on cold start and
|
|
||||||
// recovered itself, which is precisely the bypass this gate exists to stop. No verdict
|
|
||||||
// means no recovery; the next sweep retries once the roster is known.
|
|
||||||
val authority =
|
|
||||||
account.concordSessions
|
|
||||||
.sessionFor(entry.id)
|
|
||||||
?.state
|
|
||||||
?.value
|
|
||||||
?.authority
|
|
||||||
if (authority == null) {
|
|
||||||
Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" }
|
|
||||||
lastConcordRecoveryCheck.remove(entry.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val bannedHere = authority.isBanned(account.signer.pubKey)
|
|
||||||
val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue
|
|
||||||
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
|
|
||||||
Log.i("Concord") { "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}" }
|
|
||||||
if (!persistConcordEntry(merged)) {
|
|
||||||
adoptedConcordRotations.remove("${entry.id}:${merged.rootEpoch}")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1671,7 +1827,13 @@ class AccountConcordActions(
|
|||||||
if (relays.isEmpty()) return null
|
if (relays.isEmpty()) return null
|
||||||
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
|
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
|
||||||
val wraps = account.client.fetchAll(filters = filters)
|
val wraps = account.client.fetchAll(filters = filters)
|
||||||
return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) }
|
// Resolved like a join (newest per coordinate, signer-verified): a revoked link previews as
|
||||||
|
// nothing, never as the stale bundle a relay still serves. An expired one still renders.
|
||||||
|
return when (val status = ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token)) {
|
||||||
|
is InviteBundleStatus.Live -> status.invite
|
||||||
|
is InviteBundleStatus.Expired -> status.invite
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+28
-2
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
|||||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||||
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
|
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
|
||||||
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
|
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||||
@@ -139,6 +140,13 @@ class ConcordCommunitySession(
|
|||||||
*/
|
*/
|
||||||
private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch)
|
private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rekey address the rotation INTO this epoch rode on (from the prior held root), or null
|
||||||
|
* for a joiner who holds no prior root. A racing sibling rotation to this same epoch lands
|
||||||
|
* here; the app drains it for the down-only heal (CORD-06 §3).
|
||||||
|
*/
|
||||||
|
private val siblingBaseRekeyKey: GroupKey? = ConcordActions.siblingBaseRekeyPlane(entry)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The dissolution tombstone address (CORD-02 §9): derived from the community id alone, so it
|
* The dissolution tombstone address (CORD-02 §9): derived from the community id alone, so it
|
||||||
* is the same for every epoch and every member past or present.
|
* is the same for every epoch and every member past or present.
|
||||||
@@ -164,6 +172,9 @@ class ConcordCommunitySession(
|
|||||||
/** The next-epoch base-rekey stream address to watch for an inbound Refounding. */
|
/** The next-epoch base-rekey stream address to watch for an inbound Refounding. */
|
||||||
val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex
|
val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex
|
||||||
|
|
||||||
|
/** The current epoch's own base-rekey address (see [siblingBaseRekeyKey]), or null. */
|
||||||
|
val siblingBaseRekeyAddress: HexKey? get() = siblingBaseRekeyKey?.publicKeyHex
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Control Plane of every **prior** epoch we still hold a root for (address ->
|
* The Control Plane of every **prior** epoch we still hold a root for (address ->
|
||||||
* key + epoch), newest-held first and bounded like the channel backfill.
|
* key + epoch), newest-held first and bounded like the channel backfill.
|
||||||
@@ -177,7 +188,9 @@ class ConcordCommunitySession(
|
|||||||
* survives a process restart without any new storage.
|
* survives a process restart without any new storage.
|
||||||
*/
|
*/
|
||||||
private val historicalControlKeys: Map<HexKey, Pair<ControlPlaneKeys, Long>> =
|
private val historicalControlKeys: Map<HexKey, Pair<ControlPlaneKeys, Long>> =
|
||||||
entry.heldRoots
|
// Losing-fork roots of a healed race carry no Control Plane of the community's (CORD-06 §3).
|
||||||
|
ConcordRefounding
|
||||||
|
.canonicalHeldRoots(entry.heldRoots)
|
||||||
.filter { it.epoch < entry.rootEpoch }
|
.filter { it.epoch < entry.rootEpoch }
|
||||||
.sortedByDescending { it.epoch }
|
.sortedByDescending { it.epoch }
|
||||||
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
|
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
|
||||||
@@ -239,6 +252,7 @@ class ConcordCommunitySession(
|
|||||||
private val channelWrapsById = HashMap<HexKey, LinkedHashMap<HexKey, Event>>() // channelIdHex -> (wrapId -> wrap)
|
private val channelWrapsById = HashMap<HexKey, LinkedHashMap<HexKey, Event>>() // channelIdHex -> (wrapId -> wrap)
|
||||||
private val guestbookWraps = LinkedHashMap<HexKey, Event>()
|
private val guestbookWraps = LinkedHashMap<HexKey, Event>()
|
||||||
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
|
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
|
||||||
|
private val siblingRekeyWraps = LinkedHashMap<HexKey, Event>()
|
||||||
|
|
||||||
// Current channel plane pubkey -> plane (channel id, key, bound epoch), refreshed on each control
|
// 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
|
// re-fold. A Public Channel's plane derives from the root at the root epoch; a Private one's from
|
||||||
@@ -335,6 +349,7 @@ class ConcordCommunitySession(
|
|||||||
address == controlPlaneAddress ||
|
address == controlPlaneAddress ||
|
||||||
address == guestbookAddress ||
|
address == guestbookAddress ||
|
||||||
address == nextBaseRekeyAddress ||
|
address == nextBaseRekeyAddress ||
|
||||||
|
address == siblingBaseRekeyAddress ||
|
||||||
address == dissolvedAddress ||
|
address == dissolvedAddress ||
|
||||||
address in historicalControlKeys ||
|
address in historicalControlKeys ||
|
||||||
lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress }
|
lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress }
|
||||||
@@ -381,6 +396,12 @@ class ConcordCommunitySession(
|
|||||||
/** The buffered kind-3303 base-rotation wraps seen at [nextBaseRekeyAddress], for the account to drain. */
|
/** The buffered kind-3303 base-rotation wraps seen at [nextBaseRekeyAddress], for the account to drain. */
|
||||||
fun pendingBaseRekeyWraps(): List<Event> = lock.withLock { baseRekeyWraps.values.toList() }
|
fun pendingBaseRekeyWraps(): List<Event> = lock.withLock { baseRekeyWraps.values.toList() }
|
||||||
|
|
||||||
|
/** The base-rekey [GroupKey] of the rotation into this epoch (sibling heal), or null. */
|
||||||
|
fun siblingBaseRekeyKey(): GroupKey? = siblingBaseRekeyKey
|
||||||
|
|
||||||
|
/** The buffered kind-3303 wraps seen at [siblingBaseRekeyAddress], for the account's heal drain. */
|
||||||
|
fun pendingSiblingRekeyWraps(): List<Event> = lock.withLock { siblingRekeyWraps.values.toList() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every stream key whose kind-1059 wraps this session reads: the Control Plane plus
|
* Every stream key whose kind-1059 wraps this session reads: the Control Plane plus
|
||||||
* one per folded channel. These are the identities a NIP-42 relay must see the
|
* one per folded channel. These are the identities a NIP-42 relay must see the
|
||||||
@@ -415,7 +436,7 @@ class ConcordCommunitySession(
|
|||||||
* The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address)
|
* The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address)
|
||||||
* for their own isolated AUTH.
|
* for their own isolated AUTH.
|
||||||
*/
|
*/
|
||||||
fun auxStreamKeys(): List<GroupKey> = listOf(guestbookKey, nextBaseRekeyKey, dissolvedKey)
|
fun auxStreamKeys(): List<GroupKey> = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey)
|
||||||
|
|
||||||
/** The community's current Control Plane editions — the input a moderation edition chains onto. */
|
/** 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) }
|
fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) }
|
||||||
@@ -542,6 +563,11 @@ class ConcordCommunitySession(
|
|||||||
lock.withLock { baseRekeyWraps[wrap.id] = wrap }
|
lock.withLock { baseRekeyWraps[wrap.id] = wrap }
|
||||||
return ConcordIngestOutcome.STRUCTURAL
|
return ConcordIngestOutcome.STRUCTURAL
|
||||||
}
|
}
|
||||||
|
siblingBaseRekeyAddress -> {
|
||||||
|
// Same as above for a racing rotation into THIS epoch (the down-only heal).
|
||||||
|
lock.withLock { siblingRekeyWraps[wrap.id] = wrap }
|
||||||
|
return ConcordIngestOutcome.STRUCTURAL
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// A prior-epoch Control Plane wrap: buffer it and re-fold, so the anti-rollback
|
// 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
|
// floor rises as the old epochs drain in. Structural — the floor can change the
|
||||||
|
|||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
/*
|
||||||
|
* 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.concord.ConcordCommunitySession
|
||||||
|
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.HeldRoot
|
||||||
|
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||||
|
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.cord06Rekey.ConcordRefounding
|
||||||
|
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
|
||||||
|
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||||
|
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.NostrSigner
|
||||||
|
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 receive side of CORD-06 rotations as commons wires it: the `vac`-cited authority check a
|
||||||
|
* receiver runs before adopting (I9), racing rotations converging on the lowest authorized root
|
||||||
|
* and the down-only same-epoch heal (I12), and the sibling address a session watches for it.
|
||||||
|
*/
|
||||||
|
class ConcordRotationReceiveTest {
|
||||||
|
private val owner = NostrSignerInternal(KeyPair())
|
||||||
|
private val admin = NostrSignerInternal(KeyPair())
|
||||||
|
private val member = NostrSignerInternal(KeyPair())
|
||||||
|
private val now = 1_700_000_000L
|
||||||
|
|
||||||
|
private class Fixture(
|
||||||
|
val community: NewConcordCommunity,
|
||||||
|
val editions: List<ControlEdition>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A community whose owner granted [admin] a BAN role. */
|
||||||
|
private suspend fun withAdmin(): Fixture {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||||
|
val cp = community.controlPlane
|
||||||
|
val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
|
||||||
|
val roleId = ByteArray(32) { (it + 1).toByte() }
|
||||||
|
val role = RoleEntity(name = "Admin", position = 1, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire())
|
||||||
|
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role, editions, createdAt = 2L, owner = community.ownerPubKey)), cp)
|
||||||
|
editions += ConcordActions.controlEditions(listOf(ConcordModeration.grant(owner, cp, community.communityId, admin.pubKey, listOf(roleId.toHexKey()), editions, createdAt = 3L, owner = community.ownerPubKey)), cp)
|
||||||
|
return Fixture(community, editions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun entryFor(
|
||||||
|
community: NewConcordCommunity,
|
||||||
|
root: String = community.communityRoot.toHexKey(),
|
||||||
|
epoch: Long = community.rootEpoch,
|
||||||
|
heldRoots: List<HeldRoot> = emptyList(),
|
||||||
|
) = ConcordCommunityListEntry(
|
||||||
|
id = community.communityIdHex,
|
||||||
|
owner = community.ownerPubKey,
|
||||||
|
ownerSalt = community.ownerSalt.toHexKey(),
|
||||||
|
root = root,
|
||||||
|
rootEpoch = epoch,
|
||||||
|
controlPk = community.controlPkHex,
|
||||||
|
heldRoots = heldRoots,
|
||||||
|
relays = listOf("wss://r.example"),
|
||||||
|
name = "Nostrichs",
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun rotate(
|
||||||
|
community: NewConcordCommunity,
|
||||||
|
rotator: NostrSigner,
|
||||||
|
newRoot: ByteArray,
|
||||||
|
authority: AuthorityCitation?,
|
||||||
|
): List<Event> {
|
||||||
|
val newEpoch = community.rootEpoch + 1
|
||||||
|
val controlRoot = ByteArray(32) { 0x6B }
|
||||||
|
return ConcordRefounding.buildBaseRekeyWraps(
|
||||||
|
rotatorSigner = rotator,
|
||||||
|
baseRekeyKey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch),
|
||||||
|
recipientsXOnly = listOf(member.pubKey),
|
||||||
|
staffXOnly = emptySet(),
|
||||||
|
newRoot = newRoot,
|
||||||
|
newControlPk = ConcordKeyDerivation.controlSignerKey(controlRoot, community.communityId, newEpoch).publicKey,
|
||||||
|
newControlRoot = controlRoot,
|
||||||
|
newEpoch = newEpoch,
|
||||||
|
prevEpoch = community.rootEpoch,
|
||||||
|
prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey(),
|
||||||
|
createdAt = now,
|
||||||
|
authority = authority,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun receive(
|
||||||
|
f: Fixture,
|
||||||
|
wraps: List<Event>,
|
||||||
|
): ReceivedRefounding? {
|
||||||
|
val entry = entryFor(f.community)
|
||||||
|
val authority = ConcordCommunityState.fold(f.editions, f.community.communityId, f.community.ownerPubKey).authority
|
||||||
|
return ConcordActions.openBaseRekey(
|
||||||
|
wraps = wraps,
|
||||||
|
baseRekey = ConcordActions.nextBaseRekeyPlane(f.community.communityRoot, f.community.communityId, f.community.rootEpoch),
|
||||||
|
recipientSigner = member,
|
||||||
|
communityId = f.community.communityIdHex,
|
||||||
|
priorRoot = f.community.communityRoot,
|
||||||
|
rootEpoch = f.community.rootEpoch,
|
||||||
|
accept = { ConcordReceive.isHonoredRotation(entry, f.editions, authority, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- I9 ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAdminsRotationIsHonoredOnlyWithItsGrantCited() =
|
||||||
|
runTest {
|
||||||
|
val f = withAdmin()
|
||||||
|
val entry = entryFor(f.community)
|
||||||
|
val citation = ConcordReceive.rotationCitation(entry, f.editions, admin.pubKey)
|
||||||
|
assertNotNull(citation, "a BAN-holding admin has a Grant to cite")
|
||||||
|
assertNull(ConcordReceive.rotationCitation(entry, f.editions, owner.pubKey), "the owner cites nothing")
|
||||||
|
|
||||||
|
val root = ByteArray(32) { 0x22 }
|
||||||
|
assertContentEquals(root, receive(f, rotate(f.community, admin, root, citation))?.newRoot)
|
||||||
|
assertNull(receive(f, rotate(f.community, admin, root, authority = null)), "an uncited delegated rotation is dropped")
|
||||||
|
assertContentEquals(root, receive(f, rotate(f.community, owner, root, authority = null))?.newRoot, "the owner needs no citation")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRotationCitingAGrantWeHaveNotSyncedIsParked() =
|
||||||
|
runTest {
|
||||||
|
val f = withAdmin()
|
||||||
|
val real = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)!!
|
||||||
|
val ahead = AuthorityCitation(real.grantId, real.grantVersion + 1, real.grantHash)
|
||||||
|
assertNull(receive(f, rotate(f.community, admin, ByteArray(32) { 0x22 }, ahead)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aStrangerCannotRotateEvenCitingSomeonesGrant() =
|
||||||
|
runTest {
|
||||||
|
val f = withAdmin()
|
||||||
|
val stranger = NostrSignerInternal(KeyPair())
|
||||||
|
val adminsCitation = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)
|
||||||
|
assertNull(receive(f, rotate(f.community, stranger, ByteArray(32) { 0x22 }, adminsCitation)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- I12 ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun racingHonoredRotationsConvergeOnTheLowestRoot() =
|
||||||
|
runTest {
|
||||||
|
val f = withAdmin()
|
||||||
|
val citation = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)
|
||||||
|
val high = ByteArray(32) { 0x70 }
|
||||||
|
val low = ByteArray(32) { 0x05 }
|
||||||
|
val wraps = rotate(f.community, owner, high, null) + rotate(f.community, admin, low, citation)
|
||||||
|
assertContentEquals(low, receive(f, wraps)?.newRoot)
|
||||||
|
assertContentEquals(low, receive(f, wraps.reversed())?.newRoot)
|
||||||
|
|
||||||
|
// An uncited (dishonored) lower root never wins the race.
|
||||||
|
val rogue = rotate(f.community, admin, ByteArray(32) { 0x01 }, null)
|
||||||
|
assertContentEquals(high, receive(f, rotate(f.community, owner, high, null) + rogue)?.newRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theHealMovesOnlyToAStrictlyLowerSiblingAndKeepsTheLoser() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
|
||||||
|
val prior = HeldRoot(0, "aa".repeat(32), community.controlPkHex)
|
||||||
|
val adopted = entryFor(community, root = "70".repeat(32), epoch = 1, heldRoots = listOf(prior))
|
||||||
|
|
||||||
|
fun sibling(root: String) = ReceivedRefounding(root.hexToByteArray(), 1, owner.pubKey, ByteArray(32) { 3 }, null)
|
||||||
|
|
||||||
|
val healed = ConcordReceive.withHealedRoot(adopted, sibling("05".repeat(32)))
|
||||||
|
assertNotNull(healed)
|
||||||
|
assertEquals("05".repeat(32), healed.root)
|
||||||
|
assertEquals(1L, healed.rootEpoch)
|
||||||
|
assertEquals(ByteArray(32) { 3 }.toHexKey(), healed.controlPk, "the control pair is the winner's")
|
||||||
|
assertTrue(healed.heldRoots.any { it.epoch == 1L && it.key == "70".repeat(32) && it.controlPk == null }, "the losing fork's root is kept for its messages")
|
||||||
|
assertEquals(prior.key, ConcordRefounding.canonicalHeldRoots(healed.heldRoots).first { it.epoch == 0L }.key)
|
||||||
|
|
||||||
|
assertNull(ConcordReceive.withHealedRoot(adopted, sibling("90".repeat(32))), "down-only: a higher sibling never re-forks the epoch")
|
||||||
|
assertNull(ConcordReceive.withHealedRoot(adopted, sibling("70".repeat(32))))
|
||||||
|
|
||||||
|
// The next adoption keeps both same-epoch roots instead of collapsing them by epoch.
|
||||||
|
val next = ConcordReceive.withAdoptedRoot(healed, ByteArray(32) { 9 }, 2)
|
||||||
|
assertEquals(
|
||||||
|
setOf("05".repeat(32), "70".repeat(32)),
|
||||||
|
next.heldRoots
|
||||||
|
.filter { it.epoch == 1L }
|
||||||
|
.map { it.key }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
assertEquals("05".repeat(32), ConcordRefounding.canonicalHeldRoots(next.heldRoots).first { it.epoch == 1L }.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSessionWatchesTheRotationIntoItsOwnEpoch() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L)
|
||||||
|
val prior = HeldRoot(community.rootEpoch, community.communityRoot.toHexKey(), community.controlPkHex)
|
||||||
|
val entry = entryFor(community, root = "70".repeat(32), epoch = community.rootEpoch + 1, heldRoots = listOf(prior))
|
||||||
|
|
||||||
|
val sibling = ConcordActions.siblingBaseRekeyPlane(entry)
|
||||||
|
assertNotNull(sibling)
|
||||||
|
assertEquals(ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex, sibling.publicKeyHex)
|
||||||
|
assertNull(ConcordActions.siblingBaseRekeyPlane(entryFor(community)), "a joiner holding no prior root has nothing to watch")
|
||||||
|
|
||||||
|
val session = ConcordCommunitySession(entry, member.pubKey)
|
||||||
|
assertEquals(sibling.publicKeyHex, session.siblingBaseRekeyAddress)
|
||||||
|
assertTrue(session.ownsPlane(sibling.publicKeyHex))
|
||||||
|
assertTrue(session.auxStreamKeys().any { it.publicKeyHex == sibling.publicKeyHex })
|
||||||
|
|
||||||
|
val wraps = rotate(community, owner, ByteArray(32) { 0x05 }, null)
|
||||||
|
wraps.forEach { session.ingest(it) }
|
||||||
|
assertEquals(wraps.map { it.id }.toSet(), session.pendingSiblingRekeyWraps().map { it.id }.toSet())
|
||||||
|
|
||||||
|
assertTrue(ConcordSubscriptionPlanner.auxiliaryPlaneSubs(listOf(entry)).any { it.pubKeyHex == sibling.publicKeyHex })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aLosingForkRootIsNotFoldedAsAControlPlane() {
|
||||||
|
val community = "11".repeat(32)
|
||||||
|
val entry =
|
||||||
|
ConcordCommunityListEntry(
|
||||||
|
id = community,
|
||||||
|
owner = "22".repeat(32),
|
||||||
|
ownerSalt = "33".repeat(32),
|
||||||
|
root = "44".repeat(32),
|
||||||
|
rootEpoch = 2,
|
||||||
|
heldRoots = listOf(HeldRoot(1, "05".repeat(32)), HeldRoot(1, "70".repeat(32))),
|
||||||
|
relays = listOf("wss://r.example"),
|
||||||
|
)
|
||||||
|
// One Control Plane for epoch 1 (the winner's), plus the current one.
|
||||||
|
assertEquals(2, ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)).size)
|
||||||
|
assertFalse(ConcordRefounding.canonicalHeldRoots(entry.heldRoots).any { it.key == "70".repeat(32) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,17 +43,17 @@ Ranked security > interop > feature inside each group.
|
|||||||
| S1 | 02 §9 | Dissolution: no `dissolved_pk` plane, no `eid` binding, spec tombstone (chainless, no `ev`) could not even parse; a vsk-10 Control Plane edition dissolved with no binding check | **fixed** — `ConcordDissolution` (derive, build, verify with `eid == community_id`, 20014 seal, owner author); session + planner subscribe the plane; CLI `amy concord dissolve`; the Control Plane fold no longer reads vsk 10 |
|
| S1 | 02 §9 | Dissolution: no `dissolved_pk` plane, no `eid` binding, spec tombstone (chainless, no `ev`) could not even parse; a vsk-10 Control Plane edition dissolved with no binding check | **fixed** — `ConcordDissolution` (derive, build, verify with `eid == community_id`, 20014 seal, owner author); session + planner subscribe the plane; CLI `amy concord dissolve`; the Control Plane fold no longer reads vsk 10 |
|
||||||
| S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | **fixed** — `ConcordActions.currentChannelPlane`/`historicalChannelPlanes` derive a private channel only from the entry's held `privateChannels` key at its channel epoch (never the root plane); session, planner, plane registry, app verbs and CLI all go through it; a keyless private channel has no plane (`ConcordChannel.keyHeld`/`canPost()` false, composer replaced by a notice, `amy concord send` → `no_channel_key`); held keys adopt in place, and invite-delivered keys are stored on join. Creating private channels stays open (F7) |
|
| S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | **fixed** — `ConcordActions.currentChannelPlane`/`historicalChannelPlanes` derive a private channel only from the entry's held `privateChannels` key at its channel epoch (never the root plane); session, planner, plane registry, app verbs and CLI all go through it; a keyless private channel has no plane (`ConcordChannel.keyHeld`/`canPost()` false, composer replaced by a notice, `amy concord send` → `no_channel_key`); held keys adopt in place, and invite-delivered keys are stored on join. Creating private channels stays open (F7) |
|
||||||
| S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | **fixed** — `ChannelChat.delete` (examples §2.4: binding + `e` per target + `k` per kind) sealed 20013 on the channel plane (`ConcordActions.buildChannelDelete`); `Account.delete`/`deletePrivately` route Concord notes (messages, replies, reactions) to `AccountConcordActions.deleteConcordRumors`, each target on the plane of its bound epoch (Armada always uses the current plane); tapping an own Concord reaction retracts it the same way |
|
| S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | **fixed** — `ChannelChat.delete` (examples §2.4: binding + `e` per target + `k` per kind) sealed 20013 on the channel plane (`ConcordActions.buildChannelDelete`); `Account.delete`/`deletePrivately` route Concord notes (messages, replies, reactions) to `AccountConcordActions.deleteConcordRumors`, each target on the plane of its bound epoch (Armada always uses the current plane); tapping an own Concord reaction retracts it the same way |
|
||||||
| S4 | 06 §2 | Stranded recovery adopts a bundle's newer `community_root` with no continuity or authority check — a link creator can relocate every member who joined through their link | open → rekey/invite batch |
|
| S4 | 06 §2 | Stranded recovery adopts a bundle's newer `community_root` with no continuity or authority check — a link creator can relocate every member who joined through their link | **fixed** — `ConcordStrandedRecovery` only detects (`isStranded`); the background sweep never moves the base (exposes `strandedConcordCommunities`); moving forward from a bundle needs the user to re-open the link (`joinConcordViaInvite`, `amy concord recover --rejoin`). PR #23's accept-time root gate not implemented (text unavailable; genuine owner editions re-wrap into any plane, so it needs the PR's exact rule) |
|
||||||
| S5 | 04 §1 | Grant `eid` never checked against `grant_locator(cid, member)`; a second grant chain at a random coordinate overrides the canonical one, order-dependent | **fixed** — `AuthorityResolver.resolve` / `ConcordCommunityState.fold` / `authorizedHeads` take the `community_id`; a Grant edition counts only when its `eid == grant_locator(cid, content.member)` |
|
| S5 | 04 §1 | Grant `eid` never checked against `grant_locator(cid, member)`; a second grant chain at a random coordinate overrides the canonical one, order-dependent | **fixed** — `AuthorityResolver.resolve` / `ConcordCommunityState.fold` / `authorizedHeads` take the `community_id`; a Grant edition counts only when its `eid == grant_locator(cid, content.member)` |
|
||||||
| S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist `eid` unchecked | **fixed** — the Banlist folds to ONE authority-gated head at `banlist_locator(cid)`; the rank-delta rule is kept; re-heal = the writer re-applying atop the winner (`ConcordModeration.ban` again after refold) |
|
| S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist `eid` unchecked | **fixed** — the Banlist folds to ONE authority-gated head at `banlist_locator(cid)`; the rank-delta rule is kept; re-heal = the writer re-applying atop the winner (`ConcordModeration.ban` again after refold) |
|
||||||
| S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | **fixed** — `EditionFold.foldEntityGated`/`foldGated` take an author rank: authority first, then rumor id, both in the head pick (Armada `pickHead`) and in the walk's anchor/next-link choice. The walk part goes past Armada (whose `version.fold` walks on rumor id only, so a lower-id fork can strand an edition chained on the owner's sibling) — spec followed |
|
| S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | **fixed** — `EditionFold.foldEntityGated`/`foldGated` take an author rank: authority first, then rumor id, both in the head pick (Armada `pickHead`) and in the walk's anchor/next-link choice. The walk part goes past Armada (whose `version.fold` walks on rumor id only, so a lower-id fork can strand an edition chained on the owner's sibling) — spec followed |
|
||||||
| S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | **fixed** — metadata is read only at `eid == community_id` (a fold gate, `AuthorityResolver.isWellFormed`) |
|
| S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | **fixed** — metadata is read only at `eid == community_id` (a fold gate, `AuthorityResolver.isWellFormed`) |
|
||||||
| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | **fixed** — control: `ControlEdition.fromOpened` refuses a non-20014 seal; used by the session, `ConcordActions.controlEditions` (CLI) and Refounding compaction; chat: `ChannelChat.acceptOpened` requires a 20013 seal, a `CHAT_KINDS` rumor (9, 1111, 7, 5, 3302, 23311, 1740), the strict binding and a well-formed `ms`, for stored and typing wraps; `EventCache.consumeConcordRumor` refuses non-chat kinds too |
|
| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | **fixed** — control: `ControlEdition.fromOpened` refuses a non-20014 seal; used by the session, `ConcordActions.controlEditions` (CLI) and Refounding compaction; chat: `ChannelChat.acceptOpened` requires a 20013 seal, a `CHAT_KINDS` rumor (9, 1111, 7, 5, 3302, 23311, 1740), the strict binding and a well-formed `ms`, for stored and typing wraps; `EventCache.consumeConcordRumor` refuses non-chat kinds too |
|
||||||
| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | **fixed** — `ConcordStreamEnvelope` seal/wrap refuse a plaintext over 65,535 UTF-8 bytes (Armada `encryptChecked`), and open refuses an extended-format payload before decrypting |
|
| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | **fixed** — `ConcordStreamEnvelope` seal/wrap refuse a plaintext over 65,535 UTF-8 bytes (Armada `encryptChecked`), and open refuses an extended-format payload before decrypting |
|
||||||
| S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | open → rekey/invite batch |
|
| S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | **fixed** — `ConcordInviteBundle.bound`: >256 channels refused, `relays` de-duplicated + truncated to 5, applied in `parse` (link bundles) and `ConcordDirectInvite.parse`; fragments decode ≤3 relays |
|
||||||
| S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | open → rekey/invite batch |
|
| S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | **fixed** — `compactControlPlane(…, mustCarry)` throws `IncompleteControlPlaneException` on a missing honored head; app sweeps the plane paged (majority of relays DRAINED) and CLI pages it; rekey chunks are `publishAndConfirm`ed first, compaction published only after |
|
||||||
| S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | open → rekey/invite batch |
|
| S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | **fixed** — dissolved check in `drainConcordRekeys`, `recoverStrandedConcordCommunities`, `refoundConcordCommunity`, the explicit rejoin, and CLI `rekey`/`recover`/`refound` (`ConcordCommands.isDissolved`) |
|
||||||
| S14 | 05 §2 | `classify` trusts the relay filter: no signature, `pubkey == link_signer`, or `d == ""` check; a relay can forge a revocation | open → rekey/invite batch |
|
| S14 | 05 §2 | `classify` trusts the relay filter: no signature, `pubkey == link_signer`, or `d == ""` check; a relay can forge a revocation | **fixed** — `classify(wraps, linkSignerPubKey, token)` keeps only events with kind 33301, author == link signer, `d == ""` and a valid signature (`isAtCoordinate`); every caller passes the signer |
|
||||||
|
|
||||||
### Interop (Armada drops or diverges)
|
### Interop (Armada drops or diverges)
|
||||||
|
|
||||||
@@ -66,17 +66,17 @@ Ranked security > interop > feature inside each group.
|
|||||||
| I5 | 04 §2 | Role content lacks `role_id`; Armada ignores every role we mint | **fixed** — `RoleEntity.roleId` written by `defineRole`; read accepts a legacy role without it, refuses a mismatching one |
|
| I5 | 04 §2 | Role content lacks `role_id`; Armada ignores every role we mint | **fixed** — `RoleEntity.roleId` written by `defineRole`; read accepts a legacy role without it, refuses a mismatching one |
|
||||||
| I6 | 04 §1 | First edition is v0; spec says versions start at 1 | **fixed** — genesis and every new entity start at v1; v0 chains still read |
|
| I6 | 04 §1 | First edition is v0; spec says versions start at 1 | **fixed** — genesis and every new entity start at v1; v0 chains still read |
|
||||||
| I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | **fixed** — a canonical unmodeled `vsk` parses with `entityKind == null` + raw `vsk`; floors and compaction carry its gated head verbatim (11 by `PIN_MESSAGES`, 12 by `MANAGE_CHANNELS`, others by any staff bit). vsk 6/7/9/10 are no longer parsed as Control editions |
|
| I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | **fixed** — a canonical unmodeled `vsk` parses with `entityKind == null` + raw `vsk`; floors and compaction carry its gated head verbatim (11 by `PIN_MESSAGES`, 12 by `MANAGE_CHANNELS`, others by any staff bit). vsk 6/7/9/10 are no longer parsed as Control editions |
|
||||||
| I8 | 06 | Rekey `chunk` index is 0-based; Armada requires 1-based and drops all our Refoundings | open → rekey/invite batch |
|
| I8 | 06 | Rekey `chunk` index is 0-based; Armada requires 1-based and drops all our Refoundings | **fixed** — `ConcordRekey.tags` takes a 1-based index (`require 1..n`); `chunkOf` parses strict decimals and refuses 0 / `i > n`; receivers drop malformed chunks |
|
||||||
| I9 | 06 §3 | Rotations carry no `vac` | open → rekey/invite batch |
|
| I9 | 06 §3 | Rotations carry no `vac` | **fixed** — rotations carry `vac` on every chunk (`ConcordRotationAuthority.citationFor`, owner none); receivers require `ConcordReceive.isHonoredRotation` (BAN + `citationSatisfied`, Armada semantics) in the app drain and CLI `rekey`; a rotator whose chunks cite different Grants is dropped |
|
||||||
| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | open → rekey/invite batch |
|
| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | **fixed** — `ConcordRekey.chunkBlobs` budgets the rumor JSON at 40,960 bytes (Armada `REKEY_RUMOR_MAX_BYTES`) plus the 120 count cap; test pins the seal (wrap plaintext) ≤ 65,535 with 136-byte blobs |
|
||||||
| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | open → rekey/invite batch |
|
| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | **fixed** — `encodeFragment` truncates non-stock lists to 3 (stock set stays a flag); `decodeFragment` refuses count > 3 |
|
||||||
| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | open → rekey/invite batch |
|
| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | **fixed** — `findNewRoot` converges on the lowest authorized root (`accept` filter before `converge`); sessions watch the current epoch's own rekey address and `drainConcordRekeys` heals down-only (`ConcordReceive.withHealedRoot`), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: `canonicalHeldRoots`); retries reuse reserved keys (`ConcordRefounding.reserveKeys`; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys (no private channels yet, F7) |
|
||||||
| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | **fixed** — exactly one `channel` and one `epoch` tag, epoch compared as its canonical decimal string (Armada `uniqueTag`/`checkChannelBinding`); builders drop binding tags smuggled in `extraTags` |
|
| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | **fixed** — exactly one `channel` and one `epoch` tag, epoch compared as its canonical decimal string (Armada `uniqueTag`/`checkChannelBinding`); builders drop binding tags smuggled in `extraTags` |
|
||||||
| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | **fixed** — any gated channel edition with `deleted:true` retires the channel for good (Armada `everDeleted`); the channel gate refuses an empty or >64-byte name so the fold falls back to the previous candidate, and `defineChannel`/create/rename refuse to mint one |
|
| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | **fixed** — any gated channel edition with `deleted:true` retires the channel for good (Armada `everDeleted`); the channel gate refuses an empty or >64-byte name so the fold falls back to the previous candidate, and `defineChannel`/create/rename refuse to mint one |
|
||||||
| I15 | 02 §4 | No `ms` tag on chat rumors | **fixed** — every `ChannelChat` rumor carries `["ms", 0..999]` after the binding; malformed/duplicated `ms` drops the rumor; `channelMessages` and edit recency order by `created_at*1000+ms` (the shared feed still sorts by `created_at`; open PR #7 may drop `ms`) |
|
| I15 | 02 §4 | No `ms` tag on chat rumors | **fixed** — every `ChannelChat` rumor carries `["ms", 0..999]` after the binding; malformed/duplicated `ms` drops the rumor; `channelMessages` and edit recency order by `created_at*1000+ms` (the shared feed still sorts by `created_at`; open PR #7 may drop `ms`) |
|
||||||
| I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | **fixed** — four-element `q` (the `p` credit stays, as Armada keeps it). Also CORD-03 §3: your own kind-1111 thread replies can now be edited (kind 3302) like kind-9 messages |
|
| I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | **fixed** — four-element `q` (the `p` credit stays, as Armada keeps it). Also CORD-03 §3: your own kind-1111 thread replies can now be edited (kind 3302) like kind-9 messages |
|
||||||
| I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | **fixed** — `ConcordLimits`; refused on write (factory, `ConcordModeration`, app verbs, CLI) and enforced at fold like Armada: over-cap role/metadata editions fall back, a Grant's `role_ids` trim to 64, the Community keeps its 100 lowest `role_id`s. Also: `ev`/`vac`/`vsk` must be canonical decimals and a duplicate `vsk`/`eid`/`ev`/`ep`/`vac` invalidates the edition; CLI knows `VIEW_AUDIT_LOG`/`MENTION_EVERYONE`/`PIN_MESSAGES`; the app's Admin role matches Armada's `ADMIN_ALL` |
|
| I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | **fixed** — `ConcordLimits`; refused on write (factory, `ConcordModeration`, app verbs, CLI) and enforced at fold like Armada: over-cap role/metadata editions fall back, a Grant's `role_ids` trim to 64, the Community keeps its 100 lowest `role_id`s. Also: `ev`/`vac`/`vsk` must be canonical decimals and a duplicate `vsk`/`eid`/`ev`/`ep`/`vac` invalidates the edition; CLI knows `VIEW_AUDIT_LOG`/`MENTION_EVERYONE`/`PIN_MESSAGES`; the app's Admin role matches Armada's `ADMIN_ALL` |
|
||||||
| I18 | 05 §1, §4 | Join doesn't echo invite attribution; CLI join publishes no Guestbook Join; Invite List merge lets the patch win; malformed tombstones dropped | open → rekey/invite batch |
|
| I18 | 05 §1, §4 | Join doesn't echo invite attribution; CLI join publishes no Guestbook Join; Invite List merge lets the patch win; malformed tombstones dropped | **fixed** — join echoes `creator_npub`/`label` in the Guestbook Join (mints now set `creator_npub`); `amy concord join` publishes a Guestbook Join; Invite List merge is first-wins per token; untyped tombstones carried as `opaqueTombstones` and still retire their token |
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
@@ -95,6 +95,12 @@ Ranked security > interop > feature inside each group.
|
|||||||
|
|
||||||
## Spec issues to raise upstream
|
## Spec issues to raise upstream
|
||||||
|
|
||||||
|
- CORD-06 §1 counts rekey capacity in blobs ("up to 120 participants per event"), but 120 base
|
||||||
|
blobs overflow NIP-44's 65,535-byte plaintext once wrapped; the spec should state the byte budget
|
||||||
|
(Armada uses a 40,960-byte rumor ceiling).
|
||||||
|
- The rekey blob plaintext is base64-encoded before NIP-44 (signer APIs take strings); unpinned by
|
||||||
|
the spec, as Armada's own comment notes.
|
||||||
|
- Rekey seal kind (20013) and the `chunk` indexing base are implied by examples only; worth a MUST.
|
||||||
- 02 §8 and examples §6.2 cite "a dissolution payload (CORD-06 §1)", but CORD-06 defines no
|
- 02 §8 and examples §6.2 cite "a dissolution payload (CORD-06 §1)", but CORD-06 defines no
|
||||||
such payload, and Armada has none. Dangling reference.
|
such payload, and Armada has none. Dangling reference.
|
||||||
- CORD-07 §2 should require a nonce in the 27235 grant (Armada already adds one): two
|
- CORD-07 §2 should require a nonce in the 27235 grant (Armada already adds one): two
|
||||||
|
|||||||
+2
-1
@@ -83,6 +83,7 @@ object ConcordDirectInvite {
|
|||||||
if (seal !is SealEvent) return null
|
if (seal !is SealEvent) return null
|
||||||
val rumor = seal.unsealOrNull(recipientSigner) ?: return null
|
val rumor = seal.unsealOrNull(recipientSigner) ?: return null
|
||||||
if (rumor.kind != KIND) return null
|
if (rumor.kind != KIND) return null
|
||||||
return ConcordJson.decodeOrNull<CommunityInvite>(rumor.content)
|
// Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply").
|
||||||
|
return ConcordJson.decodeOrNull<CommunityInvite>(rumor.content)?.let { ConcordInviteBundle.bound(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-2
@@ -26,9 +26,11 @@ import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk
|
|||||||
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
|
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
|
||||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44
|
import com.vitorpamplona.quartz.nip44Encryption.Nip44
|
||||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||||
@@ -94,6 +96,52 @@ class MintedInviteLink(
|
|||||||
object ConcordInviteBundle {
|
object ConcordInviteBundle {
|
||||||
const val KIND = ConcordInviteBundleEvent.KIND
|
const val KIND = ConcordInviteBundleEvent.KIND
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bundle naming more Channels than this is refused before anything is allocated for it
|
||||||
|
* (CORD-05 §1: "reject a bundle carrying more than a sane channel count (Vector's ceiling is
|
||||||
|
* 256)"). A bundle is attacker-crafted input reached by following a link.
|
||||||
|
*/
|
||||||
|
const val MAX_BUNDLE_CHANNELS = 256
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bundle's `relays` are truncated to the Community's relay cap before anything connects to
|
||||||
|
* them (CORD-05 §1, CORD-02 §6's "up to 5"): a hostile link must not be a connect storm.
|
||||||
|
*/
|
||||||
|
const val MAX_COMMUNITY_RELAYS = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounds an attacker-crafted [invite] (CORD-05 §1 MUST): null when it names more than
|
||||||
|
* [MAX_BUNDLE_CHANNELS] Channels, otherwise the invite with `relays` de-duplicated and
|
||||||
|
* truncated to [MAX_COMMUNITY_RELAYS]. Every redeem path — link bundle, Direct Invite —
|
||||||
|
* goes through [validate], which applies this.
|
||||||
|
*/
|
||||||
|
fun bound(invite: CommunityInvite): CommunityInvite? {
|
||||||
|
if (invite.channels.size > MAX_BUNDLE_CHANNELS) return null
|
||||||
|
val relays =
|
||||||
|
invite.relays
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.take(MAX_COMMUNITY_RELAYS)
|
||||||
|
return if (relays == invite.relays) invite else invite.copy(relays = relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [event] is really the bundle coordinate `(33301, linkSigner, d="")` (CORD-05 §2):
|
||||||
|
* the right kind, authored by [linkSignerPubKey], an empty `d`, and a valid signature. A relay
|
||||||
|
* filter is a hint, not a proof — a relay (or anyone who can write to one) could otherwise
|
||||||
|
* serve a forged newer `vsk 9` and revoke a link it never owned.
|
||||||
|
*/
|
||||||
|
fun isAtCoordinate(
|
||||||
|
event: Event,
|
||||||
|
linkSignerPubKey: HexKey,
|
||||||
|
): Boolean {
|
||||||
|
if (event.kind != KIND) return false
|
||||||
|
if (!event.pubKey.equals(linkSignerPubKey, ignoreCase = true)) return false
|
||||||
|
val d = event.tags.firstOrNull { it.isNotEmpty() && it[0] == "d" }?.getOrNull(1) ?: ""
|
||||||
|
if (d != "") return false
|
||||||
|
return event.verify()
|
||||||
|
}
|
||||||
|
|
||||||
private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite)
|
private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite)
|
||||||
|
|
||||||
/** Builds a kind-33301 bundle event carrying [invite], encrypted under [token] and signed by [linkSignerPrivKey]. */
|
/** Builds a kind-33301 bundle event carrying [invite], encrypted under [token] and signed by [linkSignerPrivKey]. */
|
||||||
@@ -125,7 +173,10 @@ object ConcordInviteBundle {
|
|||||||
createdAt: Long,
|
createdAt: Long,
|
||||||
): Event = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)).sign(ConcordInviteBundleEvent.buildRevocation(createdAt))
|
): Event = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)).sign(ConcordInviteBundleEvent.buildRevocation(createdAt))
|
||||||
|
|
||||||
/** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */
|
/**
|
||||||
|
* Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle.
|
||||||
|
* The result is already [bound]ed (CORD-05 §1), so an over-long relay list never reaches a caller.
|
||||||
|
*/
|
||||||
fun parse(
|
fun parse(
|
||||||
event: Event,
|
event: Event,
|
||||||
token: ByteArray,
|
token: ByteArray,
|
||||||
@@ -133,7 +184,7 @@ object ConcordInviteBundle {
|
|||||||
if (event.kind != KIND) return null
|
if (event.kind != KIND) return null
|
||||||
return try {
|
return try {
|
||||||
val bundleKey = ConcordKeyDerivation.inviteBundleKey(token)
|
val bundleKey = ConcordKeyDerivation.inviteBundleKey(token)
|
||||||
ConcordJson.decodeOrNull<CommunityInvite>(Nip44.v2.decrypt(event.content, bundleKey))
|
ConcordJson.decodeOrNull<CommunityInvite>(Nip44.v2.decrypt(event.content, bundleKey))?.let { bound(it) }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -152,11 +203,25 @@ object ConcordInviteBundle {
|
|||||||
* milliseconds) resolves to [InviteBundleStatus.Expired] rather than
|
* milliseconds) resolves to [InviteBundleStatus.Expired] rather than
|
||||||
* [InviteBundleStatus.Live], so the expiry is actually enforced at the one place
|
* [InviteBundleStatus.Live], so the expiry is actually enforced at the one place
|
||||||
* every redeeming client already funnels through.
|
* every redeeming client already funnels through.
|
||||||
|
*
|
||||||
|
* Only events really at the coordinate count ([isAtCoordinate]: kind, author ==
|
||||||
|
* [linkSignerPubKey], `d == ""`, valid signature) — the relay filter is not trusted, so a
|
||||||
|
* forged newer revocation cannot kill a link, nor a forged bundle hijack one.
|
||||||
*/
|
*/
|
||||||
fun classify(
|
fun classify(
|
||||||
wraps: List<Event>,
|
wraps: List<Event>,
|
||||||
|
linkSignerPubKey: HexKey,
|
||||||
token: ByteArray,
|
token: ByteArray,
|
||||||
nowMs: Long = TimeUtils.nowMillis(),
|
nowMs: Long = TimeUtils.nowMillis(),
|
||||||
|
): InviteBundleStatus {
|
||||||
|
val genuine = wraps.filter { isAtCoordinate(it, linkSignerPubKey) }
|
||||||
|
return classifyGenuine(genuine, token, nowMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun classifyGenuine(
|
||||||
|
wraps: List<Event>,
|
||||||
|
token: ByteArray,
|
||||||
|
nowMs: Long,
|
||||||
): InviteBundleStatus {
|
): InviteBundleStatus {
|
||||||
val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent
|
val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent
|
||||||
if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked
|
if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked
|
||||||
@@ -197,6 +262,7 @@ object ConcordInviteBundle {
|
|||||||
* member. Raise with the Concord/Armada authors before diverging.
|
* member. Raise with the Concord/Armada authors before diverging.
|
||||||
*/
|
*/
|
||||||
fun validate(invite: CommunityInvite): Boolean {
|
fun validate(invite: CommunityInvite): Boolean {
|
||||||
|
if (invite.channels.size > MAX_BUNDLE_CHANNELS) return false
|
||||||
val owner = invite.owner.hexToByteArrayOrNull() ?: return false
|
val owner = invite.owner.hexToByteArrayOrNull() ?: return false
|
||||||
val salt = invite.ownerSalt.hexToByteArrayOrNull() ?: return false
|
val salt = invite.ownerSalt.hexToByteArrayOrNull() ?: return false
|
||||||
return ConcordKeyDerivation.communityId(owner, salt).toHexKey() == invite.communityId
|
return ConcordKeyDerivation.communityId(owner, salt).toHexKey() == invite.communityId
|
||||||
|
|||||||
+22
-14
@@ -62,6 +62,13 @@ object ConcordInviteLink {
|
|||||||
const val VERSION = 4
|
const val VERSION = 4
|
||||||
const val FLAG_STOCK_RELAYS = 0x01
|
const val FLAG_STOCK_RELAYS = 0x01
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fragment carries at most this many bootstrap relays (CORD-05 §3): it only has to *find*
|
||||||
|
* the bundle, which then carries the Community's authoritative relay set. The reference
|
||||||
|
* client's decoder refuses a longer list, so an encoder must never emit one.
|
||||||
|
*/
|
||||||
|
const val MAX_BOOTSTRAP_RELAYS = 3
|
||||||
|
|
||||||
private const val MARKER_WSS_HOST = 0
|
private const val MARKER_WSS_HOST = 0
|
||||||
private const val MARKER_FULL_URL = 255
|
private const val MARKER_FULL_URL = 255
|
||||||
private const val WSS_PREFIX = "wss://"
|
private const val WSS_PREFIX = "wss://"
|
||||||
@@ -69,13 +76,10 @@ object ConcordInviteLink {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes the fragment for [token] and optional [relays]. Passing null or the
|
* Encodes the fragment for [token] and optional [relays]. Passing null or the
|
||||||
* exact stock set uses flag `0x01` and emits no relay bytes; otherwise every
|
* exact stock set uses flag `0x01` and emits no relay bytes (the stock set is
|
||||||
* relay is encoded (dictionary id, `wss://` host, or full URL).
|
* flag-selected, so exempt from the cap); otherwise the first
|
||||||
*
|
* [MAX_BOOTSTRAP_RELAYS] relays are encoded (dictionary id, `wss://` host, or full
|
||||||
* The only ceiling is the format's own: the relay count is a single byte, so at
|
* URL) and the rest dropped (CORD-05 §3) — the bundle carries the full set.
|
||||||
* most 255 relays fit. Each carries its own byte cost, so a long list makes a long
|
|
||||||
* link — pick relays that can actually serve the bundle rather than pasting a
|
|
||||||
* whole relay list in.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
fun encodeFragment(
|
fun encodeFragment(
|
||||||
@@ -90,10 +94,10 @@ object ConcordInviteLink {
|
|||||||
if (useStock) {
|
if (useStock) {
|
||||||
out.add(FLAG_STOCK_RELAYS.toByte())
|
out.add(FLAG_STOCK_RELAYS.toByte())
|
||||||
} else {
|
} else {
|
||||||
require(relays.size <= 255) { "relay count must fit in one byte, was ${relays.size}" }
|
val bounded = relays.distinct().take(MAX_BOOTSTRAP_RELAYS)
|
||||||
out.add(0)
|
out.add(0)
|
||||||
out.add(relays.size.toByte())
|
out.add(bounded.size.toByte())
|
||||||
for (r in relays) {
|
for (r in bounded) {
|
||||||
val id = InviteRelayDictionary.idOf(r)
|
val id = InviteRelayDictionary.idOf(r)
|
||||||
when {
|
when {
|
||||||
id != null -> out.add(id.toByte())
|
id != null -> out.add(id.toByte())
|
||||||
@@ -119,8 +123,9 @@ object ConcordInviteLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes an invite [fragment]. Throws for a malformed fragment or a version
|
* Decodes an invite [fragment]. Throws for a malformed fragment, a version
|
||||||
* other than [VERSION] (lower = legacy, higher = newer than this client).
|
* other than [VERSION] (lower = legacy, higher = newer than this client), or more
|
||||||
|
* than [MAX_BOOTSTRAP_RELAYS] relays (CORD-05 §3, as the reference client does).
|
||||||
* Unknown dictionary ids are skipped rather than aborting the parse.
|
* Unknown dictionary ids are skipped rather than aborting the parse.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
@@ -138,7 +143,9 @@ object ConcordInviteLink {
|
|||||||
relays.addAll(InviteRelayDictionary.STOCK)
|
relays.addAll(InviteRelayDictionary.STOCK)
|
||||||
usedStock = true
|
usedStock = true
|
||||||
} else {
|
} else {
|
||||||
|
require(pos < bytes.size) { "fragment truncated" }
|
||||||
val count = bytes[pos++].toInt() and 0xFF
|
val count = bytes[pos++].toInt() and 0xFF
|
||||||
|
require(count <= MAX_BOOTSTRAP_RELAYS) { "too many bootstrap relays ($count, cap $MAX_BOOTSTRAP_RELAYS)" }
|
||||||
repeat(count) {
|
repeat(count) {
|
||||||
val marker = bytes[pos++].toInt() and 0xFF
|
val marker = bytes[pos++].toInt() and 0xFF
|
||||||
when (marker) {
|
when (marker) {
|
||||||
@@ -162,8 +169,9 @@ object ConcordInviteLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a full shareable invite URL under [base], carrying every relay in [relays]
|
* Builds a full shareable invite URL under [base], carrying the first
|
||||||
* as the bootstrap set (or the stock flag when null / exactly the stock set).
|
* [MAX_BOOTSTRAP_RELAYS] of [relays] as the bootstrap set (or the stock flag when
|
||||||
|
* null / exactly the stock set).
|
||||||
*/
|
*/
|
||||||
fun buildUrl(
|
fun buildUrl(
|
||||||
base: String,
|
base: String,
|
||||||
|
|||||||
+36
-14
@@ -33,6 +33,7 @@ import kotlinx.serialization.descriptors.elementNames
|
|||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.JsonTransformingSerializer
|
import kotlinx.serialization.json.JsonTransformingSerializer
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
@@ -86,12 +87,16 @@ class ConcordInviteListTombstone(
|
|||||||
* a newer schema. They are carried verbatim rather than dropped (re-encoding without them would
|
* a newer schema. They are carried verbatim rather than dropped (re-encoding without them would
|
||||||
* delete somebody's `signer_sk`) and rather than failing the whole read (which would refuse every
|
* delete somebody's `signer_sk`) and rather than failing the whole read (which would refuse every
|
||||||
* future mint and revoke for this account until someone else repaired the list).
|
* future mint and revoke for this account until someone else repaired the list).
|
||||||
|
*
|
||||||
|
* [opaqueTombstones] is the same for tombstones: one that does not type-check is residue we carry
|
||||||
|
* verbatim, never drop — dropping a retirement is how a stale device resurrects a revoked link.
|
||||||
*/
|
*/
|
||||||
class ConcordInviteListDocument(
|
class ConcordInviteListDocument(
|
||||||
val entries: List<ConcordInviteListEntry> = emptyList(),
|
val entries: List<ConcordInviteListEntry> = emptyList(),
|
||||||
val tombstones: List<ConcordInviteListTombstone> = emptyList(),
|
val tombstones: List<ConcordInviteListTombstone> = emptyList(),
|
||||||
val residue: JsonObject = NoExtras,
|
val residue: JsonObject = NoExtras,
|
||||||
val opaqueEntries: List<JsonObject> = emptyList(),
|
val opaqueEntries: List<JsonObject> = emptyList(),
|
||||||
|
val opaqueTombstones: List<JsonElement> = emptyList(),
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val EMPTY = ConcordInviteListDocument()
|
val EMPTY = ConcordInviteListDocument()
|
||||||
@@ -201,14 +206,16 @@ object ConcordInviteList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val opaqueTombstones = mutableListOf<JsonElement>()
|
||||||
val tombstones =
|
val tombstones =
|
||||||
(root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element ->
|
(root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element ->
|
||||||
try {
|
try {
|
||||||
val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject)
|
val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject)
|
||||||
ConcordInviteListTombstone(it.token, it.communityId, it.extras)
|
ConcordInviteListTombstone(it.token, it.communityId, it.extras)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// A tombstone we cannot read must not silently un-retire its link, but we
|
// A tombstone we cannot type must not silently un-retire its link: carry it
|
||||||
// have no token to key it by, so it can only ride along as document residue.
|
// verbatim as residue (and still honor its token in the merge, if it has one).
|
||||||
|
opaqueTombstones.add(element)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,6 +225,7 @@ object ConcordInviteList {
|
|||||||
tombstones = tombstones,
|
tombstones = tombstones,
|
||||||
residue = JsonObject(root - "entries" - "tombstones"),
|
residue = JsonObject(root - "entries" - "tombstones"),
|
||||||
opaqueEntries = opaque,
|
opaqueEntries = opaque,
|
||||||
|
opaqueTombstones = opaqueTombstones,
|
||||||
)
|
)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
@@ -238,30 +246,43 @@ object ConcordInviteList {
|
|||||||
),
|
),
|
||||||
).jsonObject
|
).jsonObject
|
||||||
|
|
||||||
// Entries we could not type ride back out untouched. Dropping them here is the data loss
|
// Entries and tombstones we could not type ride back out untouched. Dropping them here is
|
||||||
// this whole class exists to prevent — they are somebody's link signer too.
|
// the data loss this whole class exists to prevent — a link signer, or a link's retirement.
|
||||||
if (doc.opaqueEntries.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire)
|
if (doc.opaqueEntries.isEmpty() && doc.opaqueTombstones.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire)
|
||||||
val entries = JsonArray((wire["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries)
|
var out = wire
|
||||||
return ConcordJson.instance.encodeToString(JsonObject.serializer(), JsonObject(wire + ("entries" to entries)))
|
if (doc.opaqueEntries.isNotEmpty()) {
|
||||||
|
out = JsonObject(out + ("entries" to JsonArray((out["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries)))
|
||||||
|
}
|
||||||
|
if (doc.opaqueTombstones.isNotEmpty()) {
|
||||||
|
out = JsonObject(out + ("tombstones" to JsonArray((out["tombstones"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueTombstones)))
|
||||||
|
}
|
||||||
|
return ConcordJson.instance.encodeToString(JsonObject.serializer(), out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The `token` an untyped tombstone still names, if it names one as a string. */
|
||||||
|
private fun opaqueTombstoneToken(element: JsonElement): String? = ((element as? JsonObject)?.get("token") as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges [patch] onto [base], keyed by `token` — the spec's own merge key. A token present in
|
* Merges [patch] onto [base], keyed by `token` — the spec's own merge key (CORD-05 §4). An entry
|
||||||
* either side's tombstones is dropped from the result and kept tombstoned, so a retired link
|
* is **immutable once minted**, so the first copy of a token wins ([base], the published list,
|
||||||
* cannot be resurrected by a device that still has it cached. [patch] wins field-by-field on a
|
* before [patch]) and a later copy can never rewrite its `signer_sk` or url; tombstones union
|
||||||
* token both sides carry, which is what makes "read remote, apply my change, publish" converge.
|
* (first wins likewise), and a tombstone always beats an entry — terminally, so a retired link
|
||||||
|
* cannot be resurrected by a device that still has it cached. Mirrors the reference client's
|
||||||
|
* `mergeInviteLists`. A tombstone we could not type still retires the token it names.
|
||||||
*/
|
*/
|
||||||
fun merge(
|
fun merge(
|
||||||
base: ConcordInviteListDocument,
|
base: ConcordInviteListDocument,
|
||||||
patch: ConcordInviteListDocument,
|
patch: ConcordInviteListDocument,
|
||||||
): ConcordInviteListDocument {
|
): ConcordInviteListDocument {
|
||||||
val tombstones = LinkedHashMap<String, ConcordInviteListTombstone>()
|
val tombstones = LinkedHashMap<String, ConcordInviteListTombstone>()
|
||||||
for (t in base.tombstones + patch.tombstones) tombstones[t.token] = t
|
for (t in base.tombstones + patch.tombstones) tombstones.getOrPut(t.token) { t }
|
||||||
|
val opaqueTombstones = (base.opaqueTombstones + patch.opaqueTombstones).distinct()
|
||||||
|
val retired = tombstones.keys + opaqueTombstones.mapNotNull { opaqueTombstoneToken(it) }
|
||||||
|
|
||||||
val entries = LinkedHashMap<String, ConcordInviteListEntry>()
|
val entries = LinkedHashMap<String, ConcordInviteListEntry>()
|
||||||
for (e in base.entries + patch.entries) {
|
for (e in base.entries + patch.entries) {
|
||||||
if (e.token in tombstones) continue
|
if (e.token in retired) continue
|
||||||
entries[e.token] = e
|
entries.getOrPut(e.token) { e }
|
||||||
}
|
}
|
||||||
return ConcordInviteListDocument(
|
return ConcordInviteListDocument(
|
||||||
entries = entries.values.toList(),
|
entries = entries.values.toList(),
|
||||||
@@ -270,6 +291,7 @@ object ConcordInviteList {
|
|||||||
// Untyped entries survive the merge for the same reason they survive a decode: we cannot
|
// Untyped entries survive the merge for the same reason they survive a decode: we cannot
|
||||||
// read them, so we are in no position to decide they are disposable.
|
// read them, so we are in no position to decide they are disposable.
|
||||||
opaqueEntries = (base.opaqueEntries + patch.opaqueEntries).distinct(),
|
opaqueEntries = (base.opaqueEntries + patch.opaqueEntries).distinct(),
|
||||||
|
opaqueTombstones = opaqueTombstones,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-38
@@ -24,38 +24,37 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
|
|||||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stranded recovery (CORD-05/06).
|
* Stranded detection (CORD-05/06).
|
||||||
*
|
*
|
||||||
* A Refounding carries only `(newRoot, newEpoch, rotator)` — there is **no
|
* A Refounding carries only `(newRoot, newEpoch, rotator)` — there is **no
|
||||||
* recipient list** — so a member who is simply left out of the rekey recipient
|
* recipient list** — so a member who is simply left out of the rekey recipient
|
||||||
* set receives nothing and is silently stranded on the dead epoch forever, while
|
* set receives nothing and is silently stranded on the dead epoch, while everyone
|
||||||
* everyone else moves on. This is true of any member, the owner included, and
|
* else moves on. The invite link the membership was joined through
|
||||||
* cannot be prevented on the receive side.
|
* ([ConcordCommunityListEntry.inviteRef]) is re-minted at the current epoch by its
|
||||||
|
* creator, so re-resolving it and finding a **higher** epoch tells a member they
|
||||||
|
* were left behind.
|
||||||
*
|
*
|
||||||
* The way out is the invite link the membership was joined through
|
* What a bundle may NOT do is move the base (CORD-06 §2, and the reference client's
|
||||||
* ([ConcordCommunityListEntry.inviteRef]): the community keeps publishing its
|
* `isCatchUpBundle`: "It may never move the base"). Nothing binds `community_root`
|
||||||
* bundle at that same addressable coordinate, re-minted at the current epoch. So
|
* to `community_id`, so a bundle is not proof of continuity: a link creator — or
|
||||||
* a member who re-resolves their own join link and finds a **higher** epoch than
|
* anyone who ever held a link's signer — could serve a higher-epoch bundle carrying
|
||||||
* the one they hold knows they were left behind, and can merge forward.
|
* a root of their own and silently relocate every member who joined through that
|
||||||
*
|
* link onto streams they read. The base advances only by a CORD-06 §2 rekey blob
|
||||||
* This object holds only the pure decision + merge; fetching and unlocking the
|
* whose `prevcommit` proves it extends the key we hold, from a rotator our roster
|
||||||
* bundle at the link is the caller's job.
|
* authorizes. So this object only **detects** ([isStranded]); the background sweep
|
||||||
|
* never adopts anything, and the one way forward from a bundle is the user
|
||||||
|
* explicitly accepting the link again ([rejoinForward]) — the same trust decision
|
||||||
|
* as the first join, never taken on their behalf.
|
||||||
*/
|
*/
|
||||||
object ConcordStrandedRecovery {
|
object ConcordStrandedRecovery {
|
||||||
/**
|
/**
|
||||||
* True when [bundle], resolved at [entry]'s stored invite link, proves we were
|
* True when [bundle], resolved at [entry]'s stored invite link, says we were
|
||||||
* left behind: it must describe the same community and sit at a strictly higher
|
* left behind: it must describe the same community and sit at a strictly higher
|
||||||
* epoch. Same or lower is a no-op (we are current, or the bundle is stale).
|
* epoch. Same or lower is a no-op (we are current, or the bundle is stale).
|
||||||
*
|
*
|
||||||
* [bannedAtCurrentEpoch] is the caller's answer to "does the community, as I fold
|
* [bannedAtCurrentEpoch] is the caller's answer to "does the community, as I fold
|
||||||
* it right now, have me on its banlist?" — and a `true` refuses the recovery
|
* it right now, have me on its banlist?" — and a `true` answers false outright:
|
||||||
* outright. It is a required argument rather than a caller-side `if` because
|
* a removed member is not stranded, they are removed.
|
||||||
* getting it wrong turns this mechanism inside out: recovery exists so a member
|
|
||||||
* *wrongly* omitted from a rotation can catch up, but the test it performs (a
|
|
||||||
* higher epoch at a link whose unlock token an ex-member keeps forever) cannot
|
|
||||||
* tell that member apart from one the community deliberately removed. Without
|
|
||||||
* this, a Refounding — the only hard removal Concord has — is undone by our own
|
|
||||||
* background sweep a few minutes later.
|
|
||||||
*/
|
*/
|
||||||
fun isStranded(
|
fun isStranded(
|
||||||
entry: ConcordCommunityListEntry,
|
entry: ConcordCommunityListEntry,
|
||||||
@@ -68,21 +67,18 @@ object ConcordStrandedRecovery {
|
|||||||
bundle.rootEpoch > entry.rootEpoch
|
bundle.rootEpoch > entry.rootEpoch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges [entry] forward onto the higher-epoch [bundle], or returns null when
|
* The entry that results from the user **explicitly** re-accepting the invite
|
||||||
* there is nothing to do ([isStranded] is false) — so the caller can treat null
|
* link [bundle] was resolved from, while stranded on [entry] — or null when
|
||||||
* as "stay put" without a second check.
|
* [isStranded] is false. Never call this from a background sweep: adopting a
|
||||||
|
* bundle's root is a join decision (see the class note), and only the user can
|
||||||
|
* make it.
|
||||||
*
|
*
|
||||||
* The merge is epoch-monotonic (it never moves backwards, by construction of
|
* The merge is epoch-monotonic and preserves what a fresh join would lose: the
|
||||||
* [isStranded]) and preserves two things the naive "adopt the bundle" would
|
* [ConcordCommunityListEntry.inviteRef] anchor, and the existing
|
||||||
* destroy:
|
* [ConcordCommunityListEntry.heldRoots] plus the root we are leaving, so
|
||||||
*
|
* prior-epoch history stays derivable.
|
||||||
* - the [ConcordCommunityListEntry.inviteRef] anchor, so the next Refounding we
|
|
||||||
* are left out of is recoverable too; and
|
|
||||||
* - the existing [ConcordCommunityListEntry.heldRoots], plus the root we are
|
|
||||||
* leaving, so prior-epoch history the member legitimately holds stays
|
|
||||||
* derivable instead of going dark on catch-up.
|
|
||||||
*/
|
*/
|
||||||
fun mergeForward(
|
fun rejoinForward(
|
||||||
entry: ConcordCommunityListEntry,
|
entry: ConcordCommunityListEntry,
|
||||||
bundle: CommunityInvite,
|
bundle: CommunityInvite,
|
||||||
bannedAtCurrentEpoch: Boolean,
|
bannedAtCurrentEpoch: Boolean,
|
||||||
@@ -92,7 +88,7 @@ object ConcordStrandedRecovery {
|
|||||||
// Bank the epoch we are leaving with its control_pk, so its Control Plane
|
// 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
|
// stays re-subscribable for the anti-rollback floor (a split epoch's address
|
||||||
// is held, never derivable — CORD-02 §2).
|
// is held, never derivable — CORD-02 §2).
|
||||||
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }
|
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch to it.key.lowercase() }
|
||||||
|
|
||||||
return ConcordCommunityListEntry(
|
return ConcordCommunityListEntry(
|
||||||
id = entry.id,
|
id = entry.id,
|
||||||
@@ -109,10 +105,8 @@ object ConcordStrandedRecovery {
|
|||||||
name = entry.name.ifEmpty { bundle.name },
|
name = entry.name.ifEmpty { bundle.name },
|
||||||
addedAt = entry.addedAt,
|
addedAt = entry.addedAt,
|
||||||
inviteRef = entry.inviteRef,
|
inviteRef = entry.inviteRef,
|
||||||
// We were excluded from the epoch we were sitting on when we found the gap.
|
|
||||||
excludedAtEpoch = entry.rootEpoch,
|
|
||||||
// Unknown keys another client wrote are data we hold in trust: carry them forward,
|
// Unknown keys another client wrote are data we hold in trust: carry them forward,
|
||||||
// or this recovery write silently deletes them.
|
// or this write silently deletes them.
|
||||||
residue = entry.residue,
|
residue = entry.residue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+243
-33
@@ -21,7 +21,10 @@
|
|||||||
package com.vitorpamplona.quartz.concord.cord06Rekey
|
package com.vitorpamplona.quartz.concord.cord06Rekey
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||||
|
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
|
||||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||||
@@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||||
|
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The events a Refounding produces (CORD-06 §3): the [controlWraps] (the current
|
* The events a Refounding produces (CORD-06 §3): the [controlWraps] (the current
|
||||||
@@ -74,11 +78,51 @@ class ReceivedRefounding(
|
|||||||
val rotator: HexKey,
|
val rotator: HexKey,
|
||||||
val newControlPk: ByteArray? = null,
|
val newControlPk: ByteArray? = null,
|
||||||
val newControlRoot: ByteArray? = null,
|
val newControlRoot: ByteArray? = null,
|
||||||
|
/**
|
||||||
|
* The Grant the rotator claims to act under (`vac`, CORD-06 §3 "Authority"), null when absent
|
||||||
|
* (the owner cites nothing). The caller verifies it against its fold before adopting — see
|
||||||
|
* [ConcordRotationAuthority.citationSatisfied].
|
||||||
|
*/
|
||||||
|
val authority: AuthorityCitation? = null,
|
||||||
) {
|
) {
|
||||||
/** True when this was a legacy pre-split rotation (72-byte base blob). */
|
/** True when this was a legacy pre-split rotation (72-byte base blob). */
|
||||||
val legacy: Boolean get() = newControlPk == null
|
val legacy: Boolean get() = newControlPk == null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Refounding the rotator has already minted keys for (CORD-06 §3 "Failure and races"): the
|
||||||
|
* fresh [newRoot] + [newControlRoot] reserved for the rotation from ([rootEpoch], [prevCommit]).
|
||||||
|
* A retry of the same rotation MUST reuse them — rotations correlate by (rotator, newepoch,
|
||||||
|
* prevcommit), so a retry with a fresh root would merge into the first attempt's set and split
|
||||||
|
* the members across two roots at one epoch. Keep it until the rotation is adopted, then drop it.
|
||||||
|
*/
|
||||||
|
class PendingRefounding(
|
||||||
|
val communityId: HexKey,
|
||||||
|
val rootEpoch: Long,
|
||||||
|
val prevCommit: HexKey,
|
||||||
|
val newRoot: ByteArray,
|
||||||
|
val newControlRoot: ByteArray,
|
||||||
|
) {
|
||||||
|
/** True when this reservation is for the rotation that leaves [priorRoot] at [rootEpoch]. */
|
||||||
|
fun matches(
|
||||||
|
communityId: HexKey,
|
||||||
|
rootEpoch: Long,
|
||||||
|
priorRoot: ByteArray,
|
||||||
|
): Boolean =
|
||||||
|
this.communityId.equals(communityId, ignoreCase = true) &&
|
||||||
|
this.rootEpoch == rootEpoch &&
|
||||||
|
prevCommit == ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a Refounding cannot carry the whole Control Plane into the new epoch (CORD-06 §3:
|
||||||
|
* "If the Refounder cannot reliably fold all Control events, the Refounding must be aborted").
|
||||||
|
* [missing] names the entities (`eid` hex) whose honored head is not in the compaction.
|
||||||
|
*/
|
||||||
|
class IncompleteControlPlaneException(
|
||||||
|
val missing: List<String>,
|
||||||
|
) : IllegalStateException("Refounding aborted: the Control Plane could not be folded in full (${missing.size} entity head(s) missing)")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whole-community Refounding (CORD-06 §3): rotate `community_root` to sever a
|
* Whole-community Refounding (CORD-06 §3): rotate `community_root` to sever a
|
||||||
* removed member absolutely. Public Channels and the Control/Guestbook planes all
|
* removed member absolutely. Public Channels and the Control/Guestbook planes all
|
||||||
@@ -111,6 +155,11 @@ object ConcordRefounding {
|
|||||||
* @param recipientsXOnly the retained members' x-only pubkeys (hex) to re-key
|
* @param recipientsXOnly the retained members' x-only pubkeys (hex) to re-key
|
||||||
* @param staffXOnly the subset of [recipientsXOnly] that is staff (owner + Control-writing
|
* @param staffXOnly the subset of [recipientsXOnly] that is staff (owner + Control-writing
|
||||||
* permission holders, CORD-04 §3) and receives the 136-byte blob
|
* permission holders, CORD-04 §3) and receives the 136-byte blob
|
||||||
|
* @param authority the rotator's `vac` citation (CORD-06 §3 "Authority"), stamped on every
|
||||||
|
* rekey chunk; null when the owner rotates
|
||||||
|
* @param mustCarry the entity heads (`eid` hex -> version) the rotator currently honors; the
|
||||||
|
* compaction must carry each at or above that version, or the Refounding
|
||||||
|
* aborts with [IncompleteControlPlaneException] (CORD-06 §3)
|
||||||
*/
|
*/
|
||||||
suspend fun build(
|
suspend fun build(
|
||||||
rotatorSigner: NostrSigner,
|
rotatorSigner: NostrSigner,
|
||||||
@@ -125,11 +174,15 @@ object ConcordRefounding {
|
|||||||
staffXOnly: Set<HexKey>,
|
staffXOnly: Set<HexKey>,
|
||||||
createdAt: Long,
|
createdAt: Long,
|
||||||
ownerPubKey: HexKey,
|
ownerPubKey: HexKey,
|
||||||
|
authority: AuthorityCitation? = null,
|
||||||
|
mustCarry: Map<String, Long> = emptyMap(),
|
||||||
): RefoundingBuild {
|
): RefoundingBuild {
|
||||||
val newEpoch = rootEpoch + 1
|
val newEpoch = rootEpoch + 1
|
||||||
val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot)
|
val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot)
|
||||||
|
|
||||||
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, communityId, ownerPubKey)
|
// Acquired in full BEFORE anything is published (CORD-06 §3): throws when the plane cannot be
|
||||||
|
// carried whole, so a failed fold never leaves a half rotation as the only copy.
|
||||||
|
val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, communityId, ownerPubKey, mustCarry)
|
||||||
|
|
||||||
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch)
|
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch)
|
||||||
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
|
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
|
||||||
@@ -146,6 +199,7 @@ object ConcordRefounding {
|
|||||||
prevEpoch = rootEpoch,
|
prevEpoch = rootEpoch,
|
||||||
prevCommit = prevCommit,
|
prevCommit = prevCommit,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
|
authority = authority,
|
||||||
)
|
)
|
||||||
|
|
||||||
return RefoundingBuild(newRoot, newControlRoot, newEpoch, newControlKeys, controlWraps, rekeyWraps)
|
return RefoundingBuild(newRoot, newControlRoot, newEpoch, newControlKeys, controlWraps, rekeyWraps)
|
||||||
@@ -177,6 +231,7 @@ object ConcordRefounding {
|
|||||||
newControlKeys: ControlPlaneKeys,
|
newControlKeys: ControlPlaneKeys,
|
||||||
communityId: ByteArray,
|
communityId: ByteArray,
|
||||||
ownerPubKey: HexKey,
|
ownerPubKey: HexKey,
|
||||||
|
mustCarry: Map<String, Long> = emptyMap(),
|
||||||
): List<Event> {
|
): List<Event> {
|
||||||
// entity coordinate -> every edition we can open, paired with its verified seal.
|
// entity coordinate -> every edition we can open, paired with its verified seal.
|
||||||
val byCoordinate = HashMap<String, MutableList<Pair<ControlEdition, Event>>>()
|
val byCoordinate = HashMap<String, MutableList<Pair<ControlEdition, Event>>>()
|
||||||
@@ -205,19 +260,46 @@ object ConcordRefounding {
|
|||||||
val editions = byCoordinate.values.flatten()
|
val editions = byCoordinate.values.flatten()
|
||||||
val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, communityId, ownerPubKey)
|
val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, communityId, ownerPubKey)
|
||||||
val out = ArrayList<Event>(honored.size)
|
val out = ArrayList<Event>(honored.size)
|
||||||
for ((_, floor) in honored) {
|
val missing = ArrayList<String>()
|
||||||
val head = floor.known ?: continue
|
for ((entity, floor) in honored) {
|
||||||
val seal = editions.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue
|
val head = floor.known
|
||||||
|
val seal = head?.let { h -> editions.firstOrNull { it.first.rumorId == h.rumorId }?.second }
|
||||||
|
if (seal == null) {
|
||||||
|
// A head we honor whose signed seal we cannot re-wrap would silently drop the entity
|
||||||
|
// from the new epoch: abort instead (fold-all-or-abort, CORD-06 §3).
|
||||||
|
missing.add(entity)
|
||||||
|
continue
|
||||||
|
}
|
||||||
out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt))
|
out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt))
|
||||||
}
|
}
|
||||||
|
// Every head the caller already folds must survive at or above the version it honors: a
|
||||||
|
// shorter compaction means the fetch we compacted from was partial, and publishing it would
|
||||||
|
// roll the community back for every member who follows the new epoch.
|
||||||
|
for ((entity, version) in mustCarry) {
|
||||||
|
val carried = honored[entity]?.known?.version
|
||||||
|
if (carried == null || carried < version) missing.add(entity)
|
||||||
|
}
|
||||||
|
if (missing.isNotEmpty()) throw IncompleteControlPlaneException(missing.distinct())
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The version of every entity head [editions] honor (`eid` hex -> version) — what a
|
||||||
|
* Refounder passes as `mustCarry`, so the compaction aborts rather than drop a head the
|
||||||
|
* Refounder itself folds (CORD-06 §3).
|
||||||
|
*/
|
||||||
|
fun headVersions(
|
||||||
|
editions: Collection<ControlEdition>,
|
||||||
|
communityId: ByteArray,
|
||||||
|
ownerPubKey: HexKey,
|
||||||
|
): Map<String, Long> = ConcordCommunityState.authorizedHeads(editions, communityId, ownerPubKey).mapValues { it.value.version }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mints the base-rotation rekey blobs delivering [newRoot] + [newControlPk] to
|
* Mints the base-rotation rekey blobs delivering [newRoot] + [newControlPk] to
|
||||||
* [recipientsXOnly] — the [staffXOnly] subset also receiving [newControlRoot]
|
* [recipientsXOnly] — the [staffXOnly] subset also receiving [newControlRoot]
|
||||||
* in the 136-byte staff form (CORD-06 §1) — chunked at
|
* in the 136-byte staff form (CORD-06 §1) — chunked by count and bytes
|
||||||
* [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal,
|
* ([ConcordRekey.chunkBlobs], 1-based `chunk` tags, [authority] cited on every
|
||||||
|
* chunk) and wrapped (encrypted seal,
|
||||||
* rotator-signed) on the [baseRekeyKey] address so every current member — who
|
* rotator-signed) on the [baseRekeyKey] address so every current member — who
|
||||||
* precomputes that address from the prior root — receives it live.
|
* precomputes that address from the prior root — receives it live.
|
||||||
*/
|
*/
|
||||||
@@ -233,6 +315,7 @@ object ConcordRefounding {
|
|||||||
prevEpoch: Long,
|
prevEpoch: Long,
|
||||||
prevCommit: HexKey,
|
prevCommit: HexKey,
|
||||||
createdAt: Long,
|
createdAt: Long,
|
||||||
|
authority: AuthorityCitation? = null,
|
||||||
): List<Event> {
|
): List<Event> {
|
||||||
if (recipientsXOnly.isEmpty()) return emptyList()
|
if (recipientsXOnly.isEmpty()) return emptyList()
|
||||||
val staffLower = staffXOnly.mapTo(HashSet()) { it.lowercase() }
|
val staffLower = staffXOnly.mapTo(HashSet()) { it.lowercase() }
|
||||||
@@ -248,10 +331,19 @@ object ConcordRefounding {
|
|||||||
newControlRoot = if (recipient.lowercase() in staffLower) newControlRoot else null,
|
newControlRoot = if (recipient.lowercase() in staffLower) newControlRoot else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val chunks = blobs.chunked(ConcordRekey.MAX_BLOBS_PER_CHUNK)
|
// The envelope is measured once at the widest `chunk` tag this rotation could carry.
|
||||||
|
val widest = blobs.size.coerceAtLeast(1)
|
||||||
|
val envelopeTags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, widest, widest, authority)
|
||||||
|
val envelope =
|
||||||
|
RumorAssembler
|
||||||
|
.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, envelopeTags, "")
|
||||||
|
.toJson()
|
||||||
|
.encodeToByteArray()
|
||||||
|
.size
|
||||||
|
val chunks = ConcordRekey.chunkBlobs(blobs, envelope)
|
||||||
val total = chunks.size
|
val total = chunks.size
|
||||||
return chunks.mapIndexed { index, chunk ->
|
return chunks.mapIndexed { index, chunk ->
|
||||||
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index, total)
|
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index + 1, total, authority)
|
||||||
val rumor = RumorAssembler.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk))
|
val rumor = RumorAssembler.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk))
|
||||||
ConcordStreamEnvelope.wrap(rumor, baseRekeyKey, rotatorSigner, encrypted = true, createdAt = createdAt)
|
ConcordStreamEnvelope.wrap(rumor, baseRekeyKey, rotatorSigner, encrypted = true, createdAt = createdAt)
|
||||||
}
|
}
|
||||||
@@ -260,14 +352,25 @@ object ConcordRefounding {
|
|||||||
/**
|
/**
|
||||||
* Receives a base rotation for the member behind [recipientSigner]: opens the
|
* Receives a base rotation for the member behind [recipientSigner]: opens the
|
||||||
* kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]),
|
* kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]),
|
||||||
* verifies each is a well-formed root rotation to [newEpoch] whose `prevcommit`
|
* verifies each is a well-formed root rotation to `rootEpoch + 1` whose
|
||||||
* continues the [priorRoot] the member holds, and returns the delivered new
|
* `prevepoch`/`prevcommit` continue the [priorRoot] the member holds, and returns
|
||||||
* root and Control Plane keys (with the rotator's real pubkey, so the caller
|
* the delivered new root and Control Plane keys with the rotator's real pubkey and
|
||||||
* can authorize it against the folded roster). A staff blob's delivered secret
|
* `vac` citation, so the caller can authorize it against the folded roster.
|
||||||
* must derive to exactly the delivered `control_pk` (CORD-02 §5) — a
|
*
|
||||||
* mismatched pair is refused rather than adopting a plane split from its
|
* A rekey rumor must ride an **encrypted** (20013) seal (CORD-02 §5, Appendix B); a
|
||||||
* readers. Null if no chunk carries this member's blob — which only means
|
* malformed `chunk` tag (0-based, `i > n`, non-decimal) or `vac` tag drops the
|
||||||
* "removed" once the caller confirms it holds every chunk of the rotation.
|
* chunk, and a rotator whose chunks cite different Grants is distrusted whole. 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.
|
||||||
|
*
|
||||||
|
* Race convergence (CORD-06 §3): among the candidates [accept] admits (the
|
||||||
|
* caller's authority check — authorize BEFORE converging, or an unauthorized
|
||||||
|
* lower root would win), the lexicographically lowest new base key wins; the
|
||||||
|
* control pair rides the winner's blob and is never compared.
|
||||||
|
*
|
||||||
|
* 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(
|
suspend fun findNewRoot(
|
||||||
wraps: List<Event>,
|
wraps: List<Event>,
|
||||||
@@ -276,31 +379,138 @@ object ConcordRefounding {
|
|||||||
communityId: ByteArray,
|
communityId: ByteArray,
|
||||||
priorRoot: ByteArray,
|
priorRoot: ByteArray,
|
||||||
rootEpoch: Long,
|
rootEpoch: Long,
|
||||||
): ReceivedRefounding? {
|
accept: (ReceivedRefounding) -> Boolean = { true },
|
||||||
|
): ReceivedRefounding? = converge(findNewRoots(wraps, baseRekeyKey, recipientSigner, communityId, priorRoot, rootEpoch).filter(accept))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every candidate rotation delivering this member a new root from [priorRoot] at
|
||||||
|
* [rootEpoch] (one per rotator; see [findNewRoot] for the checks), unauthorized and
|
||||||
|
* unconverged. Callers normally want [findNewRoot].
|
||||||
|
*/
|
||||||
|
suspend fun findNewRoots(
|
||||||
|
wraps: List<Event>,
|
||||||
|
baseRekeyKey: GroupKey,
|
||||||
|
recipientSigner: NostrSigner,
|
||||||
|
communityId: ByteArray,
|
||||||
|
priorRoot: ByteArray,
|
||||||
|
rootEpoch: Long,
|
||||||
|
): List<ReceivedRefounding> {
|
||||||
val newEpoch = rootEpoch + 1
|
val newEpoch = rootEpoch + 1
|
||||||
val expectedScope = ConcordRekey.ROOT_SCOPE.toHexKey()
|
val expectedScope = ConcordRekey.ROOT_SCOPE.toHexKey()
|
||||||
val expectedCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
|
val expectedCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
|
||||||
|
|
||||||
|
// Pass 1: the well-formed chunks of this continuity point, grouped by rotator.
|
||||||
|
val byRotator = LinkedHashMap<HexKey, MutableList<RekeyChunk>>()
|
||||||
for (wrap in wraps) {
|
for (wrap in wraps) {
|
||||||
val opened = ConcordStreamEnvelope.openOrNull(wrap, baseRekeyKey) ?: continue
|
val opened = ConcordStreamEnvelope.openOrNull(wrap, baseRekeyKey) ?: continue
|
||||||
|
// Rekey seals are encrypted (CORD-02 §5): a plaintext seal would expose the rotator.
|
||||||
|
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) continue
|
||||||
val rumor = opened.rumor
|
val rumor = opened.rumor
|
||||||
if (rumor.kind != ConcordRekey.KIND) continue
|
if (rumor.kind != ConcordRekey.KIND) continue
|
||||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE) != expectedScope) continue
|
if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE)?.lowercase() != expectedScope) continue
|
||||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)?.toLongOrNull() != newEpoch) continue
|
if (rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)?.toLongOrNull() != newEpoch) continue
|
||||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT) != expectedCommit) continue
|
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVEPOCH)?.toLongOrNull() != rootEpoch) continue
|
||||||
|
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT)?.lowercase() != expectedCommit) continue
|
||||||
val blobs = ConcordRekey.decodeContent(rumor.content)
|
if (ConcordRekey.chunkOf(rumor.tags) == null) continue
|
||||||
val rotatorXOnly = opened.author.hexToByteArray()
|
// A present-but-malformed citation is a corrupt chunk; absent means the owner acts.
|
||||||
val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue
|
val vacTag = rumor.tags.firstOrNull { it.isNotEmpty() && it[0] == VacTag.TAG_NAME }
|
||||||
val controlRoot = payload.newControlRoot
|
val citation = if (vacTag == null) null else VacTag.parse(vacTag) ?: continue
|
||||||
val controlPk = payload.newControlPk
|
byRotator.getOrPut(opened.author.lowercase()) { ArrayList() }.add(RekeyChunk(opened.author, rumor.content, citation))
|
||||||
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
|
|
||||||
|
// Pass 2: per rotator, find this member's blob.
|
||||||
|
val out = ArrayList<ReceivedRefounding>()
|
||||||
|
for ((_, chunks) in byRotator) {
|
||||||
|
// Every chunk of one rotation must cite the same Grant; a disagreeing set is distrusted.
|
||||||
|
val citations = chunks.map { c -> c.citation?.let { VacTag.assemble(it).joinToString(",") } }.distinct()
|
||||||
|
if (citations.size > 1) continue
|
||||||
|
val rotator = chunks.first().rotator
|
||||||
|
val rotatorXOnly = rotator.hexToByteArray()
|
||||||
|
for (chunk in chunks) {
|
||||||
|
val blobs = ConcordRekey.decodeContent(chunk.content)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
out.add(ReceivedRefounding(payload.newKey, newEpoch, rotator, controlPk, controlRoot, chunk.citation))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RekeyChunk(
|
||||||
|
val rotator: HexKey,
|
||||||
|
val content: String,
|
||||||
|
val citation: AuthorityCitation?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The winner among authorized candidates racing to one epoch (CORD-06 §3): the
|
||||||
|
* lexicographically lowest new base key. Every client computes the same winner, so
|
||||||
|
* concurrent Refoundings converge; null on no candidates.
|
||||||
|
*/
|
||||||
|
fun converge(candidates: List<ReceivedRefounding>): ReceivedRefounding? = candidates.minWithOrNull { a, b -> compareKeys(a.newRoot, b.newRoot) }
|
||||||
|
|
||||||
|
/** Unsigned lexicographic order of two keys — the order the convergence rule compares in. */
|
||||||
|
fun compareKeys(
|
||||||
|
a: ByteArray,
|
||||||
|
b: ByteArray,
|
||||||
|
): Int {
|
||||||
|
for (i in 0 until minOf(a.size, b.size)) {
|
||||||
|
val x = a[i].toInt() and 0xFF
|
||||||
|
val y = b[i].toInt() and 0xFF
|
||||||
|
if (x != y) return x - y
|
||||||
|
}
|
||||||
|
return a.size - b.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The down-only heal (CORD-06 §3): true when [candidate] should replace the [held] root
|
||||||
|
* of the same epoch — only a **strictly lower** sibling does, so a flaky fetch that
|
||||||
|
* returns only the higher sibling can never re-fork a settled epoch.
|
||||||
|
*/
|
||||||
|
fun healsTo(
|
||||||
|
held: ByteArray,
|
||||||
|
candidate: ByteArray,
|
||||||
|
): Boolean = compareKeys(candidate, held) < 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The held roots a client folds Control from: per epoch, the lowest key — the one the
|
||||||
|
* convergence rule settled on. A higher sibling at the same epoch is a losing fork's root,
|
||||||
|
* kept only so the messages sent into that fork stay readable (CORD-06 §3: "Both forks'
|
||||||
|
* keys are retained"); its Control Plane is not the community's.
|
||||||
|
*/
|
||||||
|
fun canonicalHeldRoots(heldRoots: List<HeldRoot>): List<HeldRoot> =
|
||||||
|
heldRoots
|
||||||
|
.groupBy { it.epoch }
|
||||||
|
.values
|
||||||
|
.mapNotNull { sameEpoch -> sameEpoch.minWithOrNull { a, b -> a.key.lowercase().compareTo(b.key.lowercase()) } }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keys for the Refounding that leaves [priorRoot] at [rootEpoch]: [pending] when it
|
||||||
|
* was reserved for exactly this rotation (a retry — CORD-06 §3 requires every step to be
|
||||||
|
* idempotent, so a retry must re-deliver the SAME root), a fresh pair otherwise. The
|
||||||
|
* caller persists the result before publishing anything and drops it once adopted.
|
||||||
|
*/
|
||||||
|
fun reserveKeys(
|
||||||
|
pending: PendingRefounding?,
|
||||||
|
communityId: HexKey,
|
||||||
|
rootEpoch: Long,
|
||||||
|
priorRoot: ByteArray,
|
||||||
|
): PendingRefounding {
|
||||||
|
if (pending != null && pending.matches(communityId, rootEpoch, priorRoot)) return pending
|
||||||
|
return PendingRefounding(
|
||||||
|
communityId = communityId.lowercase(),
|
||||||
|
rootEpoch = rootEpoch,
|
||||||
|
prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey(),
|
||||||
|
newRoot = RandomInstance.bytes(32),
|
||||||
|
newControlRoot = RandomInstance.bytes(32),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-9
@@ -20,7 +20,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.concord.cord06Rekey
|
package com.vitorpamplona.quartz.concord.cord06Rekey
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
|
||||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
@@ -85,7 +87,13 @@ object ConcordRekey {
|
|||||||
return RekeyBlob(locator, wrapped)
|
return RekeyBlob(locator, wrapped)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The kind-3303 rumor tags for a rekey chunk. */
|
/**
|
||||||
|
* The kind-3303 rumor tags for a rekey chunk. [chunkIndex] is **1-based** (CORD-06 §2's
|
||||||
|
* "chunk i of n"; the reference client refuses an index below 1, so a 0-based chunk makes the
|
||||||
|
* whole rotation unreadable to it). [authority] is the rotator's `vac` citation (CORD-06 §3
|
||||||
|
* "Authority", CORD-04 §5), carried on EVERY chunk so a partial holder can judge authority;
|
||||||
|
* null when the owner rotates.
|
||||||
|
*/
|
||||||
fun tags(
|
fun tags(
|
||||||
scopeId: ByteArray,
|
scopeId: ByteArray,
|
||||||
newEpoch: Long,
|
newEpoch: Long,
|
||||||
@@ -93,14 +101,78 @@ object ConcordRekey {
|
|||||||
prevCommit: HexKey,
|
prevCommit: HexKey,
|
||||||
chunkIndex: Int,
|
chunkIndex: Int,
|
||||||
chunkTotal: Int,
|
chunkTotal: Int,
|
||||||
): Array<Array<String>> =
|
authority: AuthorityCitation? = null,
|
||||||
arrayOf(
|
): Array<Array<String>> {
|
||||||
arrayOf(TAG_SCOPE, scopeId.toHexKey()),
|
require(chunkTotal >= 1 && chunkIndex in 1..chunkTotal) { "chunk must be 1..n, was $chunkIndex of $chunkTotal" }
|
||||||
arrayOf(TAG_NEWEPOCH, newEpoch.toString()),
|
val base =
|
||||||
arrayOf(TAG_PREVEPOCH, prevEpoch.toString()),
|
arrayOf(
|
||||||
arrayOf(TAG_PREVCOMMIT, prevCommit),
|
arrayOf(TAG_SCOPE, scopeId.toHexKey()),
|
||||||
arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()),
|
arrayOf(TAG_NEWEPOCH, newEpoch.toString()),
|
||||||
)
|
arrayOf(TAG_PREVEPOCH, prevEpoch.toString()),
|
||||||
|
arrayOf(TAG_PREVCOMMIT, prevCommit),
|
||||||
|
arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()),
|
||||||
|
)
|
||||||
|
return if (authority == null) base else base + arrayOf(VacTag.assemble(authority))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strict decimal (`0|[1-9][0-9]*`): digits only, no sign, exponent, radix prefix, padding or leading zero. */
|
||||||
|
private fun strictDecimal(value: String?): Int? {
|
||||||
|
if (value.isNullOrEmpty() || value.length > 9 || !value.all { it in '0'..'9' }) return null
|
||||||
|
if (value.length > 1 && value[0] == '0') return null
|
||||||
|
return value.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `["chunk", i, n]` position of a kind-3303 rumor as a 1-based `(i, n)` pair, or null when
|
||||||
|
* the tag is malformed: non-decimal, `i < 1`, `n < 1` or `i > n` (a 0-based chunk included).
|
||||||
|
* An absent tag reads as the single chunk `(1, 1)`, as the reference client does.
|
||||||
|
*/
|
||||||
|
fun chunkOf(tags: Array<Array<String>>): Pair<Int, Int>? {
|
||||||
|
val tag = tags.firstOrNull { it.isNotEmpty() && it[0] == TAG_CHUNK } ?: return 1 to 1
|
||||||
|
val index = strictDecimal(tag.getOrNull(1)) ?: return null
|
||||||
|
val count = strictDecimal(tag.getOrNull(2)) ?: return null
|
||||||
|
if (index < 1 || count < 1 || index > count) return null
|
||||||
|
return index to count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits [blobs] into chunks that each fit one kind-3303 rumor: at most
|
||||||
|
* [MAX_BLOBS_PER_CHUNK] blobs AND a rumor JSON of at most [REKEY_RUMOR_MAX_BYTES], given the
|
||||||
|
* [envelopeBytes] the rumor costs with an empty content (measure it at the widest `chunk` tag
|
||||||
|
* the rotation can carry — over-reserving only makes chunks smaller). Mirrors the reference
|
||||||
|
* client's budget byte for byte: the content's own `[]`, one comma between blobs, and each
|
||||||
|
* blob at its JSON-escaped length inside the content string. A lone over-budget blob is kept
|
||||||
|
* (blobs are indivisible). Always yields at least one (possibly empty) chunk.
|
||||||
|
*/
|
||||||
|
fun chunkBlobs(
|
||||||
|
blobs: List<RekeyBlob>,
|
||||||
|
envelopeBytes: Int,
|
||||||
|
): List<List<RekeyBlob>> {
|
||||||
|
val budget = REKEY_RUMOR_MAX_BYTES - envelopeBytes
|
||||||
|
val chunks = ArrayList<List<RekeyBlob>>()
|
||||||
|
var current = ArrayList<RekeyBlob>()
|
||||||
|
var used = 2 // the content's own "[]"
|
||||||
|
for (blob in blobs) {
|
||||||
|
val cost = escapedJsonLength(blob)
|
||||||
|
if (current.isNotEmpty() && (current.size >= MAX_BLOBS_PER_CHUNK || used + 1 + cost > budget)) {
|
||||||
|
chunks.add(current)
|
||||||
|
current = ArrayList()
|
||||||
|
used = 2
|
||||||
|
}
|
||||||
|
used += cost + (if (current.isNotEmpty()) 1 else 0)
|
||||||
|
current.add(blob)
|
||||||
|
}
|
||||||
|
if (current.isNotEmpty() || chunks.isEmpty()) chunks.add(current)
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A blob's byte length inside the rumor's JSON-escaped `content` string (without outer quotes). */
|
||||||
|
private fun escapedJsonLength(blob: RekeyBlob): Int {
|
||||||
|
val raw = encodeContent(listOf(blob)).let { it.substring(1, it.length - 1) }
|
||||||
|
var extra = 0
|
||||||
|
for (c in raw) if (c == '"' || c == '\\') extra++
|
||||||
|
return raw.encodeToByteArray().size + extra
|
||||||
|
}
|
||||||
|
|
||||||
/** Serializes a chunk's blobs into the kind-3303 rumor content. */
|
/** Serializes a chunk's blobs into the kind-3303 rumor content. */
|
||||||
fun encodeContent(blobs: List<RekeyBlob>): String = ConcordJson.instance.encodeToString(ListSerializer(RekeyBlob.serializer()), blobs)
|
fun encodeContent(blobs: List<RekeyBlob>): String = ConcordJson.instance.encodeToString(ListSerializer(RekeyBlob.serializer()), blobs)
|
||||||
@@ -118,6 +190,16 @@ object ConcordRekey {
|
|||||||
/** CORD-06 §1: a single kind-3303 event carries at most this many per-recipient blobs. */
|
/** CORD-06 §1: a single kind-3303 event carries at most this many per-recipient blobs. */
|
||||||
const val MAX_BLOBS_PER_CHUNK = 120
|
const val MAX_BLOBS_PER_CHUNK = 120
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte ceiling on one kind-3303 rumor's JSON, beside the 120-blob count cap. Base blobs are
|
||||||
|
* wider than channel ones, and 120 of them pushed the wrap's NIP-44 plaintext (the seal, which
|
||||||
|
* carries the rumor's own NIP-44 ciphertext as base64) past the 65,535-byte cap. 40,960 is the
|
||||||
|
* top of the NIP-44 padding bucket that still wraps — the reference client's
|
||||||
|
* `REKEY_RUMOR_MAX_BYTES`. Capacity: 120 blobs at 72 bytes (the count cap binds), 99 at 104,
|
||||||
|
* 90 at 136. Finer chunking is always wire-legal.
|
||||||
|
*/
|
||||||
|
const val REKEY_RUMOR_MAX_BYTES = 40_960
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a rekey blob for one recipient using [rotatorSigner] instead of a raw
|
* Builds a rekey blob for one recipient using [rotatorSigner] instead of a raw
|
||||||
* private key, so a NIP-46 bunker rotator can mint blobs without exposing its
|
* private key, so a NIP-46 bunker rotator can mint blobs without exposing its
|
||||||
|
|||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* 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.ConcordCommunityState
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
|
||||||
|
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `vac` authority citation on a rotation (CORD-06 §3 "Authority", CORD-04 §5): a rotation
|
||||||
|
* cites the Grant its rotator acts under like any authority action, so a just-demoted admin's
|
||||||
|
* rotation is never honored by a lagging client. The owner cites nothing — the `community_id`
|
||||||
|
* proves them.
|
||||||
|
*
|
||||||
|
* The citation is a *sync floor*, not the verdict: a verifier refuses the rotation until it
|
||||||
|
* holds at least the cited Grant edition, then resolves the rotator's rank against its current
|
||||||
|
* roster (the caller's `hasPermission(BAN)` check). Mirrors the reference client's
|
||||||
|
* `citationSatisfied`: a newer Grant head than the cited one satisfies, the same version must
|
||||||
|
* match the edition hash (a fork is refused), an older head parks the rotation (fail closed).
|
||||||
|
*
|
||||||
|
* [heads] is the authority-gated head of every Control entity keyed by `eid` hex — exactly
|
||||||
|
* [ConcordCommunityState.authorizedHeads] over the current epoch's editions ([headsOf]).
|
||||||
|
*/
|
||||||
|
object ConcordRotationAuthority {
|
||||||
|
/** The authority-gated entity heads a citation is minted from and checked against. */
|
||||||
|
fun headsOf(
|
||||||
|
editions: Collection<ControlEdition>,
|
||||||
|
communityIdHex: HexKey,
|
||||||
|
ownerPubKey: HexKey,
|
||||||
|
): Map<String, EntityFloor> = ConcordCommunityState.authorizedHeads(editions, communityIdHex.hexToByteArray(), ownerPubKey)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The citation [actor] puts on a rotation: their own Grant's current head, or null for the
|
||||||
|
* owner (who cites nothing) and for an actor with no Grant (whose rotation nobody honors).
|
||||||
|
*/
|
||||||
|
fun citationFor(
|
||||||
|
communityIdHex: HexKey,
|
||||||
|
actor: HexKey,
|
||||||
|
ownerPubKey: HexKey,
|
||||||
|
heads: Map<String, EntityFloor>,
|
||||||
|
): AuthorityCitation? {
|
||||||
|
if (actor.equals(ownerPubKey, ignoreCase = true)) return null
|
||||||
|
val eid = ConcordKeyDerivation.grantCoordinate(communityIdHex.hexToByteArray(), actor.lowercase().hexToByteArray())
|
||||||
|
val head = heads[eid.toHexKey()] ?: return null
|
||||||
|
val hash = head.hashHex.hexToByteArrayOrNull() ?: return null
|
||||||
|
return AuthorityCitation(eid, head.version, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether [citation] authorizes [actor]'s rotation under the verifier's [heads]. */
|
||||||
|
fun citationSatisfied(
|
||||||
|
communityIdHex: HexKey,
|
||||||
|
actor: HexKey,
|
||||||
|
ownerPubKey: HexKey,
|
||||||
|
citation: AuthorityCitation?,
|
||||||
|
heads: Map<String, EntityFloor>,
|
||||||
|
): Boolean {
|
||||||
|
if (actor.equals(ownerPubKey, ignoreCase = true)) return true
|
||||||
|
if (citation == null) return false
|
||||||
|
// Must name the actor's OWN Grant coordinate.
|
||||||
|
val eid = ConcordKeyDerivation.grantCoordinate(communityIdHex.hexToByteArray(), actor.lowercase().hexToByteArray()).toHexKey()
|
||||||
|
if (citation.grantId.toHexKey() != eid) return false
|
||||||
|
val head = heads[eid] ?: return false
|
||||||
|
return when {
|
||||||
|
head.version > citation.grantVersion -> true
|
||||||
|
// At exactly it: the hash must match our fold's winner, not a fork.
|
||||||
|
head.version == citation.grantVersion -> head.hashHex.equals(citation.grantHash.toHexKey(), ignoreCase = true)
|
||||||
|
// Behind it: park until the Grant arrives.
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-25
@@ -31,9 +31,12 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
|||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,21 +58,14 @@ class ConcordInviteClassifyTest {
|
|||||||
name = "Nostrichs",
|
name = "Nostrichs",
|
||||||
)
|
)
|
||||||
|
|
||||||
/** A raw kind-33301 event at the link-signer coordinate carrying an arbitrary [vsk] wire value. */
|
/** A kind-33301 event at the coordinate of [linkSigner], signed by it, carrying an arbitrary [vsk] wire value. */
|
||||||
private fun coordinateEvent(
|
private fun coordinateEvent(
|
||||||
linkSignerPubKey: String,
|
linkSigner: ByteArray,
|
||||||
vsk: String,
|
vsk: String,
|
||||||
createdAt: Long,
|
createdAt: Long,
|
||||||
content: String = "",
|
content: String = "",
|
||||||
) = Event(
|
dTag: String = "",
|
||||||
id = "00".repeat(32),
|
): Event = NostrSignerSync(KeyPair(privKey = linkSigner)).sign(createdAt, ConcordInviteBundleEvent.KIND, arrayOf(arrayOf("d", dTag), arrayOf(VskTag.TAG_NAME, vsk)), content)
|
||||||
pubKey = linkSignerPubKey,
|
|
||||||
createdAt = createdAt,
|
|
||||||
kind = ConcordInviteBundleEvent.KIND,
|
|
||||||
tags = arrayOf(arrayOf("d", ""), VskTag.TAG_NAME.let { arrayOf(it, vsk) }),
|
|
||||||
content = content,
|
|
||||||
sig = "00".repeat(64),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun liveBundleOpens() =
|
fun liveBundleOpens() =
|
||||||
@@ -77,7 +73,7 @@ class ConcordInviteClassifyTest {
|
|||||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
|
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||||
|
|
||||||
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token)
|
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token)
|
||||||
assertTrue(status is InviteBundleStatus.Live)
|
assertTrue(status is InviteBundleStatus.Live)
|
||||||
assertEquals(community.communityIdHex, status.invite.communityId)
|
assertEquals(community.communityIdHex, status.invite.communityId)
|
||||||
}
|
}
|
||||||
@@ -89,11 +85,11 @@ class ConcordInviteClassifyTest {
|
|||||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
|
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||||
|
|
||||||
// A newer vsk=9 tombstone at the same coordinate buries the still-openable bundle.
|
// A newer vsk=9 tombstone at the same coordinate buries the still-openable bundle.
|
||||||
val tombstone = coordinateEvent(minted.linkSignerPubKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
|
val tombstone = coordinateEvent(minted.linkSignerPrivKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
|
||||||
|
|
||||||
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.token))
|
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.linkSignerPubKey, minted.token))
|
||||||
// Order of the fetched list must not matter — newest createdAt wins regardless.
|
// Order of the fetched list must not matter — newest createdAt wins regardless.
|
||||||
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.token))
|
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.linkSignerPubKey, minted.token))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -111,7 +107,7 @@ class ConcordInviteClassifyTest {
|
|||||||
|
|
||||||
// Both fetch orders must resolve to the re-mint — `fetchAll` gives no ordering guarantee.
|
// Both fetch orders must resolve to the re-mint — `fetchAll` gives no ordering guarantee.
|
||||||
listOf(listOf(minted.bundleEvent, remint), listOf(remint, minted.bundleEvent)).forEach { wraps ->
|
listOf(listOf(minted.bundleEvent, remint), listOf(remint, minted.bundleEvent)).forEach { wraps ->
|
||||||
val status = ConcordInviteBundle.classify(wraps, minted.token)
|
val status = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token)
|
||||||
assertTrue(status is InviteBundleStatus.Live)
|
assertTrue(status is InviteBundleStatus.Live)
|
||||||
assertEquals("bb".repeat(32), status.invite.communityRoot)
|
assertEquals("bb".repeat(32), status.invite.communityRoot)
|
||||||
assertEquals(2L, status.invite.rootEpoch)
|
assertEquals(2L, status.invite.rootEpoch)
|
||||||
@@ -123,8 +119,9 @@ class ConcordInviteClassifyTest {
|
|||||||
runTest {
|
runTest {
|
||||||
// A mis-posted registry (vsk=8) at the bundle coordinate — the exact shape of the
|
// A mis-posted registry (vsk=8) at the bundle coordinate — the exact shape of the
|
||||||
// relayop.xyz link that hung — is present but not a vsk=6 bundle we can open.
|
// relayop.xyz link that hung — is present but not a vsk=6 bundle we can open.
|
||||||
val registry = coordinateEvent("aa".repeat(32), ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable")
|
val signer = KeyPair()
|
||||||
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16)))
|
val registry = coordinateEvent(signer.privKey!!, ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable")
|
||||||
|
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), signer.pubKey.toHexKey(), ByteArray(16)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,11 +149,11 @@ class ConcordInviteClassifyTest {
|
|||||||
val wraps = listOf(minted.bundleEvent)
|
val wraps = listOf(minted.bundleEvent)
|
||||||
|
|
||||||
// Before the expiry the very same bundle still opens…
|
// Before the expiry the very same bundle still opens…
|
||||||
val live = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs - 1)
|
val live = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token, nowMs = expiresAtMs - 1)
|
||||||
assertTrue(live is InviteBundleStatus.Live)
|
assertTrue(live is InviteBundleStatus.Live)
|
||||||
|
|
||||||
// …and after it, the join path must refuse it (not Live) while the preview data survives.
|
// …and after it, the join path must refuse it (not Live) while the preview data survives.
|
||||||
val expired = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs + 1)
|
val expired = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token, nowMs = expiresAtMs + 1)
|
||||||
assertTrue(expired is InviteBundleStatus.Expired)
|
assertTrue(expired is InviteBundleStatus.Expired)
|
||||||
assertEquals(community.communityIdHex, expired.invite.communityId)
|
assertEquals(community.communityIdHex, expired.invite.communityId)
|
||||||
}
|
}
|
||||||
@@ -167,12 +164,12 @@ class ConcordInviteClassifyTest {
|
|||||||
runTest {
|
runTest {
|
||||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||||
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
|
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||||
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live)
|
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun emptyFetchIsAbsent() {
|
fun emptyFetchIsAbsent() {
|
||||||
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16)))
|
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), "aa".repeat(32), ByteArray(16)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -201,12 +198,12 @@ class ConcordInviteClassifyTest {
|
|||||||
|
|
||||||
// End to end: what the creator publishes is what every redeemer then resolves.
|
// End to end: what the creator publishes is what every redeemer then resolves.
|
||||||
val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L)
|
val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L)
|
||||||
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.token))
|
assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.linkSignerPubKey, minted.token))
|
||||||
|
|
||||||
// And a re-mint that lands AFTER the grave un-revokes the link, which is exactly why the
|
// And a re-mint that lands AFTER the grave un-revokes the link, which is exactly why the
|
||||||
// refresh path must skip a coordinate it did not resolve Live first.
|
// refresh path must skip a coordinate it did not resolve Live first.
|
||||||
val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, inviteFor(community), createdAt = 3L)
|
val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, inviteFor(community), createdAt = 3L)
|
||||||
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.token) is InviteBundleStatus.Live)
|
assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.linkSignerPubKey, minted.token) is InviteBundleStatus.Live)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -218,6 +215,86 @@ class ConcordInviteClassifyTest {
|
|||||||
"""{"content":"ApoDjyzcHUg2imEiqw6Gsfpc2O86r+CMtMor+jc8ZlgrYwlI6CCmX7qGGEQvEJ5537nINE9H09Ro8RtEghpYgwkhdPHS274RpklFmuyLMdcoC5u1EVhppu8BrlHZ0YBfw3GX1Ui0uwy3V/J+rvrYiLhdREmwlK39JAX8sZfzCUhVtDMCgLVy03dwdpTC1Kj/ZeZJTYhJ8qmaN2273jgBTno/bFLzJlYvbANss69Tg53mljcmdSyhMlZ8z1kuenm1zkrPO5yHvi//r25tXkXb580OCkWxTmEwFzo20ntMgFnVSwVRvLZelOZt++tMevqi2Z5asvDgG7RytHP/0vLxxPzmjH0No+nITsxcmDbEweoKvSSzoc/7DYzENmfmrLXgP2KU/eE6CpTcSNaedLVKbAu9XptdtV8ruZxHjVBh1wpOwXkETEdqqvbCiR4TCNWzqbmwRKJ+acvZLBxhXcpfqmRsolaATU4sZKLs4iu92YpMIuUDh2Pquu0Daiz/IGnVe7BPb7E/gSd9NBFIxds6Nk1DbP8XKMRtYmWdTforUPWZqdM4EOtt8AcNpALRmsbEF26Gyd6t4/81bQPh+7WhI97lR/KkdWtKxNjjJ4CoJLgceyHuwbxXnFR23IWhzvQpBY12MBeYOw9oizvEzEGhEqpUns6LkH2sUNRRXbneNNvVgCEk6BK7j6Dxi95mcGJDEtOW+coE1SjhnfrwjIsdJL7cUEyC5DHFKuvxUi0iw/1I6b3AfZV5+A1tssEE2dhDv8uw6B3/a5EfMURFDqSfmGw1btdPPJ3+yjo1yYu2BtbYa4U++GtaAJfmNPrsB9lm4YgXuwCCRSpI2+TR9H2ntWM2j3HVdXqOpg3kfX82o9KFndo2g+7vGrOAyfL1jcybluq7AxPEV6D5yBky82MjoMeS0vSM6ytYu+0jheWPwDVs/3iPTELHPeDXAZOaw76ISBvNsXcxHvFsSiZBguBr+ucZOUnazVRAYIsmm/WNcIJu+6tfbyupqFCo5wkus6lKN2RNYIH1SRIi163cdBDhTBOdZoI2WcDr+SSW2fHtZutk7fW5IkJvSuy5xlke+YW/u3uzvriAIRmVDtk/fKISKEnMj2G47JdGn6EiHf+2+XfUSuDiliJb62pPXWBupinbb9HEW0tuyPHYGACH0/GA/egr6KMgI6YSh+BWS8vniMRTkmouKCzL5Csvc+2txC9LrfodrMF2R3jFZ1nig0mYzTQ9HvhqA2Uc+YG06iZtRaU7KqH6fMZYzPbjrxVOliyXR2G6","created_at":1784122846,"id":"112701bc1541c10b92f5a105e2e1f1813e591936e20075ec6a53c8bb8d235d81","kind":33301,"pubkey":"7177ccb8e8786c152e4960765f03fbceb7419d36a26e693a6399319760e7fd30","sig":"80eb4b49d70d73d35c1026b9c06d0fab280787950b5df412ebe4cdd05fcacadb4d20419c4e732749ed2698186a321069b4329ebd93fe21d8594d7392bf6445e0","tags":[["d",""],["vsk","8"]]}"""
|
"""{"content":"ApoDjyzcHUg2imEiqw6Gsfpc2O86r+CMtMor+jc8ZlgrYwlI6CCmX7qGGEQvEJ5537nINE9H09Ro8RtEghpYgwkhdPHS274RpklFmuyLMdcoC5u1EVhppu8BrlHZ0YBfw3GX1Ui0uwy3V/J+rvrYiLhdREmwlK39JAX8sZfzCUhVtDMCgLVy03dwdpTC1Kj/ZeZJTYhJ8qmaN2273jgBTno/bFLzJlYvbANss69Tg53mljcmdSyhMlZ8z1kuenm1zkrPO5yHvi//r25tXkXb580OCkWxTmEwFzo20ntMgFnVSwVRvLZelOZt++tMevqi2Z5asvDgG7RytHP/0vLxxPzmjH0No+nITsxcmDbEweoKvSSzoc/7DYzENmfmrLXgP2KU/eE6CpTcSNaedLVKbAu9XptdtV8ruZxHjVBh1wpOwXkETEdqqvbCiR4TCNWzqbmwRKJ+acvZLBxhXcpfqmRsolaATU4sZKLs4iu92YpMIuUDh2Pquu0Daiz/IGnVe7BPb7E/gSd9NBFIxds6Nk1DbP8XKMRtYmWdTforUPWZqdM4EOtt8AcNpALRmsbEF26Gyd6t4/81bQPh+7WhI97lR/KkdWtKxNjjJ4CoJLgceyHuwbxXnFR23IWhzvQpBY12MBeYOw9oizvEzEGhEqpUns6LkH2sUNRRXbneNNvVgCEk6BK7j6Dxi95mcGJDEtOW+coE1SjhnfrwjIsdJL7cUEyC5DHFKuvxUi0iw/1I6b3AfZV5+A1tssEE2dhDv8uw6B3/a5EfMURFDqSfmGw1btdPPJ3+yjo1yYu2BtbYa4U++GtaAJfmNPrsB9lm4YgXuwCCRSpI2+TR9H2ntWM2j3HVdXqOpg3kfX82o9KFndo2g+7vGrOAyfL1jcybluq7AxPEV6D5yBky82MjoMeS0vSM6ytYu+0jheWPwDVs/3iPTELHPeDXAZOaw76ISBvNsXcxHvFsSiZBguBr+ucZOUnazVRAYIsmm/WNcIJu+6tfbyupqFCo5wkus6lKN2RNYIH1SRIi163cdBDhTBOdZoI2WcDr+SSW2fHtZutk7fW5IkJvSuy5xlke+YW/u3uzvriAIRmVDtk/fKISKEnMj2G47JdGn6EiHf+2+XfUSuDiliJb62pPXWBupinbb9HEW0tuyPHYGACH0/GA/egr6KMgI6YSh+BWS8vniMRTkmouKCzL5Csvc+2txC9LrfodrMF2R3jFZ1nig0mYzTQ9HvhqA2Uc+YG06iZtRaU7KqH6fMZYzPbjrxVOliyXR2G6","created_at":1784122846,"id":"112701bc1541c10b92f5a105e2e1f1813e591936e20075ec6a53c8bb8d235d81","kind":33301,"pubkey":"7177ccb8e8786c152e4960765f03fbceb7419d36a26e693a6399319760e7fd30","sig":"80eb4b49d70d73d35c1026b9c06d0fab280787950b5df412ebe4cdd05fcacadb4d20419c4e732749ed2698186a321069b4329ebd93fe21d8594d7392bf6445e0","tags":[["d",""],["vsk","8"]]}"""
|
||||||
val event = Event.fromJson(json)
|
val event = Event.fromJson(json)
|
||||||
val token = "c0277c415fe2ecc901a22b2f23dca5bf".hexToByteArray()
|
val token = "c0277c415fe2ecc901a22b2f23dca5bf".hexToByteArray()
|
||||||
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), token))
|
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), event.pubKey, token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- S14: the relay filter is a hint, not a proof (CORD-05 §2) ----------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aForgedNewerRevocationByAnotherKeyDoesNotRevoke() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||||
|
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
|
||||||
|
|
||||||
|
// A relay serves a newer vsk=9 "at the coordinate" — but signed by someone else.
|
||||||
|
val forger = KeyPair()
|
||||||
|
val forged = coordinateEvent(forger.privKey!!, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
|
||||||
|
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, forged), minted.linkSignerPubKey, minted.token))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aNewerRevocationClaimingTheSignerButBadlySignedDoesNotRevoke() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||||
|
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
|
||||||
|
|
||||||
|
// Right pubkey, garbage signature: exactly what a relay could fabricate without the secret.
|
||||||
|
val forged =
|
||||||
|
Event(
|
||||||
|
id = "00".repeat(32),
|
||||||
|
pubKey = minted.linkSignerPubKey,
|
||||||
|
createdAt = 2L,
|
||||||
|
kind = ConcordInviteBundleEvent.KIND,
|
||||||
|
tags = arrayOf(arrayOf("d", ""), arrayOf(VskTag.TAG_NAME, ControlEntityKind.INVITE_REVOKED.wire)),
|
||||||
|
content = "",
|
||||||
|
sig = "00".repeat(64),
|
||||||
|
)
|
||||||
|
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, forged), minted.linkSignerPubKey, minted.token))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRevocationAtAnotherDTagIsNotThisCoordinate() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||||
|
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
|
||||||
|
|
||||||
|
// Genuinely signed by the link signer, but at a different `d` — a different coordinate.
|
||||||
|
val elsewhere = coordinateEvent(minted.linkSignerPrivKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L, dTag = "x")
|
||||||
|
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(minted.bundleEvent, elsewhere), minted.linkSignerPubKey, minted.token))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBundleFromTheWrongAuthorIsAbsent() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||||
|
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L)
|
||||||
|
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(listOf(minted.bundleEvent), "aa".repeat(32), minted.token))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- S11: bundle bounds (CORD-05 §1) --------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBundleNamingTooManyChannelsIsRefused() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||||
|
val huge = inviteFor(community).copy(channels = List(ConcordInviteBundle.MAX_BUNDLE_CHANNELS + 1) { InviteChannel(id = it.toString(), epoch = 0) })
|
||||||
|
val minted = ConcordInviteBundle.mintLink("https://vector.chat", huge, createdAt = 1L)
|
||||||
|
assertNull(ConcordInviteBundle.bound(huge))
|
||||||
|
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token))
|
||||||
|
|
||||||
|
val atCap = inviteFor(community).copy(channels = List(ConcordInviteBundle.MAX_BUNDLE_CHANNELS) { InviteChannel(id = it.toString(), epoch = 0) })
|
||||||
|
val ok = ConcordInviteBundle.mintLink("https://vector.chat", atCap, createdAt = 1L)
|
||||||
|
assertIs<InviteBundleStatus.Live>(ConcordInviteBundle.classify(listOf(ok.bundleEvent), ok.linkSignerPubKey, ok.token))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBundlesRelaysAreTruncatedToTheCommunityCap() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||||
|
val relays = List(40) { "wss://r$it.example" }
|
||||||
|
val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community).copy(relays = relays + relays), createdAt = 1L)
|
||||||
|
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token)
|
||||||
|
assertIs<InviteBundleStatus.Live>(status)
|
||||||
|
assertEquals(relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), status.invite.relays)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-16
@@ -32,6 +32,7 @@ import kotlin.test.assertEquals
|
|||||||
import kotlin.test.assertFailsWith
|
import kotlin.test.assertFailsWith
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class ConcordInviteLinkTest {
|
class ConcordInviteLinkTest {
|
||||||
@@ -67,9 +68,10 @@ class ConcordInviteLinkTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildUrlCarriesEveryRelayOfAnOversizedList() {
|
fun buildUrlTruncatesAnOversizedListToTheBootstrapCap() {
|
||||||
// A community with five relays used to crash the mint ("at most 3 relays, was 5").
|
// A community with five relays used to crash the mint ("at most 3 relays, was 5"), and was
|
||||||
// There is no cap below the format's own, so all five make the round trip.
|
// then fixed by carrying all five — which the reference client's decoder refuses. The
|
||||||
|
// fragment only has to find the bundle (CORD-05 §3), so the first three make the trip.
|
||||||
val relays =
|
val relays =
|
||||||
listOf(
|
listOf(
|
||||||
"wss://one.example",
|
"wss://one.example",
|
||||||
@@ -80,7 +82,7 @@ class ConcordInviteLinkTest {
|
|||||||
)
|
)
|
||||||
val parsed = ConcordInviteLink.parseUrl(ConcordInviteLink.buildUrl("https://vector.chat", signer, token, relays))
|
val parsed = ConcordInviteLink.parseUrl(ConcordInviteLink.buildUrl("https://vector.chat", signer, token, relays))
|
||||||
assertNotNull(parsed)
|
assertNotNull(parsed)
|
||||||
assertEquals(relays, parsed.fragment.relays)
|
assertEquals(relays.take(3), parsed.fragment.relays)
|
||||||
assertContentEquals(token, parsed.fragment.token)
|
assertContentEquals(token, parsed.fragment.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,18 +96,6 @@ class ConcordInviteLinkTest {
|
|||||||
assertEquals(InviteRelayDictionary.STOCK, parsed.fragment.relays)
|
assertEquals(InviteRelayDictionary.STOCK, parsed.fragment.relays)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun encodesTheLargestRelayListTheCountByteCanHold() {
|
|
||||||
// 255 is the format ceiling, not a policy one: the relay count is a single byte.
|
|
||||||
val relays = List(255) { "wss://relay$it.example" }
|
|
||||||
val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays))
|
|
||||||
assertEquals(relays, frag.relays)
|
|
||||||
assertContentEquals(token, frag.token)
|
|
||||||
|
|
||||||
// One more would silently wrap the count byte to 0 and strand every relay, so it throws.
|
|
||||||
assertFailsWith<IllegalArgumentException> { ConcordInviteLink.encodeFragment(token, relays + "wss://overflow.example") }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
fun rejectsWrongVersion() {
|
fun rejectsWrongVersion() {
|
||||||
@@ -134,4 +124,32 @@ class ConcordInviteLinkTest {
|
|||||||
assertContentEquals(k, ConcordKeyDerivation.inviteBundleKey(token))
|
assertContentEquals(k, ConcordKeyDerivation.inviteBundleKey(token))
|
||||||
assertFalse(k.toHexKey() == ConcordKeyDerivation.inviteBundleKey(ByteArray(16) { 0x09 }).toHexKey())
|
assertFalse(k.toHexKey() == ConcordKeyDerivation.inviteBundleKey(ByteArray(16) { 0x09 }).toHexKey())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- I11: at most 3 bootstrap relays (CORD-05 §3) -------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodingTruncatesToThreeBootstrapRelays() {
|
||||||
|
val token = ByteArray(16) { 7 }
|
||||||
|
val relays = listOf("wss://a.example", "wss://b.example", "wss://c.example", "wss://d.example", "wss://e.example")
|
||||||
|
val decoded = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays))
|
||||||
|
assertEquals(relays.take(ConcordInviteLink.MAX_BOOTSTRAP_RELAYS), decoded.relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theStockSetIsExemptFromTheCap() {
|
||||||
|
val decoded = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(ByteArray(16), InviteRelayDictionary.STOCK))
|
||||||
|
assertEquals(InviteRelayDictionary.STOCK, decoded.relays)
|
||||||
|
assertTrue(decoded.usedStockRelays)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
@Test
|
||||||
|
fun decodingRefusesMoreThanThreeBootstrapRelays() {
|
||||||
|
// version 4, flags 0, count 4, four dictionary ids, then the token — what a non-conforming
|
||||||
|
// encoder would emit and the reference client's decoder throws on.
|
||||||
|
val bytes = byteArrayOf(4, 0, 4, 1, 2, 3, 4) + ByteArray(16)
|
||||||
|
val fragment = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes)
|
||||||
|
assertFailsWith<IllegalArgumentException> { ConcordInviteLink.decodeFragment(fragment) }
|
||||||
|
assertNull(ConcordInviteLink.parseUrl("https://x/invite/" + ConcordInviteLink.buildUrl("https://x", "aa".repeat(32), ByteArray(16)).substringAfter("/invite/").substringBefore('#') + "#" + fragment))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
@@ -194,4 +194,40 @@ class ConcordInviteListTest {
|
|||||||
assertTrue(!live.isExpired(nowSecs = 99))
|
assertTrue(!live.isExpired(nowSecs = 99))
|
||||||
assertTrue(!forever.isExpired(nowSecs = Long.MAX_VALUE), "no expiry means it never elapses")
|
assertTrue(!forever.isExpired(nowSecs = Long.MAX_VALUE), "no expiry means it never elapses")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- I18: entries are immutable; malformed tombstones ride as residue (CORD-05 §4) ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anExistingTokensEntryIsImmutableSoTheFirstCopyWins() {
|
||||||
|
// The published list (base) already holds t1; a device's patch carrying a different copy of
|
||||||
|
// the same token must not rewrite its signer_sk or url (the reference client's first-wins).
|
||||||
|
val base = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t1", "sk-original", "c", "url-original", createdAt = 1)))
|
||||||
|
val patch = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t1", "sk-other", "c", "url-other", label = "late", createdAt = 9)))
|
||||||
|
|
||||||
|
val merged = ConcordInviteList.merge(base, patch)
|
||||||
|
|
||||||
|
assertEquals(1, merged.entries.size)
|
||||||
|
assertEquals("sk-original", merged.entries.first().signerSk)
|
||||||
|
assertEquals("url-original", merged.entries.first().url)
|
||||||
|
assertEquals(null, merged.entries.first().label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTombstoneThatFailsToTypeCheckIsCarriedNotDropped() {
|
||||||
|
val json =
|
||||||
|
"""
|
||||||
|
{ "entries": [ { "token": "aa", "signer_sk": "bb", "community_id": "cc", "url": "u" } ],
|
||||||
|
"tombstones": [ { "token": "aa", "community_id": {"weird": true}, "mark": "grave" } ] }
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val doc = ConcordInviteList.decodeOrNull(json)!!
|
||||||
|
assertEquals(0, doc.tombstones.size)
|
||||||
|
assertEquals(1, doc.opaqueTombstones.size, "the untyped tombstone is kept")
|
||||||
|
assertTrue(ConcordInviteList.encode(doc).contains("grave"), "an untyped tombstone was lost on re-encode")
|
||||||
|
|
||||||
|
// It still retires the token it names: a merge must not let the entry stay live.
|
||||||
|
val merged = ConcordInviteList.merge(doc, ConcordInviteListDocument.EMPTY)
|
||||||
|
assertTrue(merged.entries.none { it.token == "aa" }, "an untyped tombstone must still beat its entry")
|
||||||
|
assertTrue(ConcordInviteList.encode(merged).contains("grave"), "merge dropped an untyped tombstone")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ class ConcordInviteRelayopInteropTest {
|
|||||||
assertEquals("https://blossom.primal.net/a85a6b8f68cf602591b16846e1605f8034587b7220b44d7076d09f5e3bf5af71.jpg", invite.icon?.url)
|
assertEquals("https://blossom.primal.net/a85a6b8f68cf602591b16846e1605f8034587b7220b44d7076d09f5e3bf5af71.jpg", invite.icon?.url)
|
||||||
assertEquals(false, invite.icon?.isResolvable())
|
assertEquals(false, invite.icon?.isResolvable())
|
||||||
|
|
||||||
val status = ConcordInviteBundle.classify(listOf(event), token)
|
val status = ConcordInviteBundle.classify(listOf(event), event.pubKey, token)
|
||||||
assertIs<InviteBundleStatus.Live>(status)
|
assertIs<InviteBundleStatus.Live>(status)
|
||||||
assertEquals("3rd times a charm?", status.invite.name)
|
assertEquals("3rd times a charm?", status.invite.name)
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-11
@@ -75,14 +75,14 @@ class ConcordStrandedRecoveryTest {
|
|||||||
name = "Gamers",
|
name = "Gamers",
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- merge forward --------------------------------------------------------
|
// ---- explicit re-join (the user accepts the link again) ---------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun higherEpochBundleMergesForwardKeepingAnchorAndHistory() {
|
fun anExplicitRejoinOfAHigherEpochBundleKeepsAnchorAndHistory() {
|
||||||
val prior = HeldRoot(0L, "aa".repeat(32))
|
val prior = HeldRoot(0L, "aa".repeat(32))
|
||||||
val stranded = entry(epoch = 1, heldRoots = listOf(prior))
|
val stranded = entry(epoch = 1, heldRoots = listOf(prior))
|
||||||
|
|
||||||
val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false)
|
val merged = ConcordStrandedRecovery.rejoinForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false)
|
||||||
assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind")
|
assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind")
|
||||||
|
|
||||||
// adopted the new epoch's access root
|
// adopted the new epoch's access root
|
||||||
@@ -98,22 +98,21 @@ class ConcordStrandedRecoveryTest {
|
|||||||
assertTrue(merged.heldRoots.any { it.epoch == 0L && it.key == prior.key })
|
assertTrue(merged.heldRoots.any { it.epoch == 0L && it.key == prior.key })
|
||||||
assertTrue(merged.heldRoots.any { it.epoch == 1L && it.key == "bb".repeat(32) })
|
assertTrue(merged.heldRoots.any { it.epoch == 1L && it.key == "bb".repeat(32) })
|
||||||
|
|
||||||
// identity is untouched and we record where we were dropped
|
// identity is untouched
|
||||||
assertEquals(communityId, merged.id)
|
assertEquals(communityId, merged.id)
|
||||||
assertEquals(stranded.addedAt, merged.addedAt)
|
assertEquals(stranded.addedAt, merged.addedAt)
|
||||||
assertEquals(1L, merged.excludedAtEpoch)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sameEpochBundleIsANoOp() {
|
fun sameEpochBundleIsANoOp() {
|
||||||
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false))
|
assertNull(ConcordStrandedRecovery.rejoinForward(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false))
|
||||||
assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false))
|
assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun lowerEpochBundleIsANoOp() {
|
fun lowerEpochBundleIsANoOp() {
|
||||||
// Epoch-monotonic: a stale bundle must never walk the membership backwards.
|
// Epoch-monotonic: a stale bundle must never walk the membership backwards.
|
||||||
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3), bannedAtCurrentEpoch = false))
|
assertNull(ConcordStrandedRecovery.rejoinForward(entry(epoch = 7), bundle(epoch = 3), bannedAtCurrentEpoch = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -121,12 +120,12 @@ class ConcordStrandedRecoveryTest {
|
|||||||
// Direct invites and legacy entries have no anchor — expected, not an error.
|
// Direct invites and legacy entries have no anchor — expected, not an error.
|
||||||
val noAnchor = entry(epoch = 1, ref = null)
|
val noAnchor = entry(epoch = 1, ref = null)
|
||||||
assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false))
|
assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false))
|
||||||
assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false))
|
assertNull(ConcordStrandedRecovery.rejoinForward(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun bundleForAnotherCommunityIsIgnored() {
|
fun bundleForAnotherCommunityIsIgnored() {
|
||||||
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)), bannedAtCurrentEpoch = false))
|
assertNull(ConcordStrandedRecovery.rejoinForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)), bannedAtCurrentEpoch = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the bare `<naddr>#<fragment>` anchor form ----------------------------
|
// ---- the bare `<naddr>#<fragment>` anchor form ----------------------------
|
||||||
@@ -260,8 +259,24 @@ class ConcordStrandedRecoveryTest {
|
|||||||
// very epoch a Refounding rotated them out of. See A2 in docs/concord-soft-ban-audit.md.
|
// very epoch a Refounding rotated them out of. See A2 in docs/concord-soft-ban-audit.md.
|
||||||
val stranded = entry(epoch = 1)
|
val stranded = entry(epoch = 1)
|
||||||
assertFalse(ConcordStrandedRecovery.isStranded(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true))
|
assertFalse(ConcordStrandedRecovery.isStranded(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true))
|
||||||
assertNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true))
|
assertNull(ConcordStrandedRecovery.rejoinForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true))
|
||||||
// ...and the legitimate case still works, so the gate is not just "recovery off".
|
// ...and the legitimate case still works, so the gate is not just "recovery off".
|
||||||
assertNotNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false))
|
assertNotNull(ConcordStrandedRecovery.rejoinForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S4: detection is all a bundle may do on its own. The class exposes no function that moves a
|
||||||
|
* held community's base from a bundle without the user re-accepting the link — a link creator
|
||||||
|
* could otherwise relocate every member who joined through their link onto a root they chose.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aHostileHigherEpochBundleIsOnlyDetectedNeverAdoptedBySweep() {
|
||||||
|
val held = entry(epoch = 1)
|
||||||
|
val hostile = bundle(epoch = 2, root = "ee".repeat(32))
|
||||||
|
// The sweep's question: are we stranded? Yes — and that is all it learns.
|
||||||
|
assertTrue(ConcordStrandedRecovery.isStranded(held, hostile, bannedAtCurrentEpoch = false))
|
||||||
|
// The held entry itself is untouched by detection.
|
||||||
|
assertEquals("bb".repeat(32), held.root)
|
||||||
|
assertEquals(1L, held.rootEpoch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+368
@@ -0,0 +1,368 @@
|
|||||||
|
/*
|
||||||
|
* 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.HeldRoot
|
||||||
|
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
|
||||||
|
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
|
||||||
|
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
|
||||||
|
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.NostrSigner
|
||||||
|
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.assertFailsWith
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORD-06 wire conformance against the spec and the reference client: 1-based chunks (I8),
|
||||||
|
* byte-budgeted chunks under the NIP-44 cap (I10), the `vac` citation on rotations (I9),
|
||||||
|
* race convergence + idempotent retry (I12), fold-all-or-abort compaction (S12), and the
|
||||||
|
* encrypted rekey seal.
|
||||||
|
*/
|
||||||
|
class ConcordRekeyConformanceTest {
|
||||||
|
private val owner = NostrSignerInternal(KeyPair())
|
||||||
|
private val admin = NostrSignerInternal(KeyPair())
|
||||||
|
private val member = NostrSignerInternal(KeyPair())
|
||||||
|
private val now = 1_700_000_000L
|
||||||
|
private val controlRoot = ByteArray(32) { 0x6B }
|
||||||
|
|
||||||
|
private suspend fun rotation(
|
||||||
|
community: NewConcordCommunity,
|
||||||
|
rotator: NostrSigner,
|
||||||
|
newRoot: ByteArray,
|
||||||
|
recipients: List<String>,
|
||||||
|
staff: Set<String> = emptySet(),
|
||||||
|
authority: AuthorityCitation? = null,
|
||||||
|
): List<Event> {
|
||||||
|
val newEpoch = community.rootEpoch + 1
|
||||||
|
return ConcordRefounding.buildBaseRekeyWraps(
|
||||||
|
rotatorSigner = rotator,
|
||||||
|
baseRekeyKey = baseRekey(community),
|
||||||
|
recipientsXOnly = recipients,
|
||||||
|
staffXOnly = staff,
|
||||||
|
newRoot = newRoot,
|
||||||
|
newControlPk = ConcordKeyDerivation.controlSignerKey(controlRoot, community.communityId, newEpoch).publicKey,
|
||||||
|
newControlRoot = controlRoot,
|
||||||
|
newEpoch = newEpoch,
|
||||||
|
prevEpoch = community.rootEpoch,
|
||||||
|
prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey(),
|
||||||
|
createdAt = now,
|
||||||
|
authority = authority,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun baseRekey(community: NewConcordCommunity): GroupKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, community.rootEpoch + 1)
|
||||||
|
|
||||||
|
private fun rumorsOf(
|
||||||
|
wraps: List<Event>,
|
||||||
|
key: GroupKey,
|
||||||
|
) = wraps.map { assertNotNull(ConcordStreamEnvelope.openOrNull(it, key)) }
|
||||||
|
|
||||||
|
private fun members(n: Int) = List(n) { KeyPair().pubKey.toHexKey() }
|
||||||
|
|
||||||
|
// ---- I8: chunk indices are 1-based --------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chunksAreNumberedFromOne() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Test", now)
|
||||||
|
val wraps = rotation(community, owner, ByteArray(32) { 1 }, members(250) + member.pubKey)
|
||||||
|
val opened = rumorsOf(wraps, baseRekey(community))
|
||||||
|
val chunks = opened.map { o -> o.rumor.tags.first { it[0] == ConcordRekey.TAG_CHUNK } }
|
||||||
|
assertEquals((1..wraps.size).map { it.toString() }, chunks.map { it[1] })
|
||||||
|
assertTrue(chunks.all { it[2] == wraps.size.toString() })
|
||||||
|
assertTrue(opened.all { ConcordRekey.chunkOf(it.rumor.tags) != null })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chunkTagParsingIsStrict() {
|
||||||
|
fun chunk(vararg v: String) = arrayOf(arrayOf(ConcordRekey.TAG_CHUNK, *v))
|
||||||
|
assertEquals(1 to 1, ConcordRekey.chunkOf(emptyArray()), "absent reads as the only chunk")
|
||||||
|
assertEquals(2 to 3, ConcordRekey.chunkOf(chunk("2", "3")))
|
||||||
|
assertNull(ConcordRekey.chunkOf(chunk("0", "2")), "0-based is malformed")
|
||||||
|
assertNull(ConcordRekey.chunkOf(chunk("3", "2")))
|
||||||
|
assertNull(ConcordRekey.chunkOf(chunk("1", "0")))
|
||||||
|
assertNull(ConcordRekey.chunkOf(chunk("01", "2")))
|
||||||
|
assertNull(ConcordRekey.chunkOf(chunk("+1", "2")))
|
||||||
|
assertNull(ConcordRekey.chunkOf(chunk("1")))
|
||||||
|
assertFailsWith<IllegalArgumentException> { ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, 1, 0, "ab".repeat(32), 0, 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aZeroBasedChunkIsDropped() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Test", now)
|
||||||
|
val newEpoch = community.rootEpoch + 1
|
||||||
|
val blob = ConcordRekey.blobForSigner(owner, member.pubKey.hexToByteArray(), ConcordRekey.ROOT_SCOPE, newEpoch, ByteArray(32) { 1 })
|
||||||
|
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey()
|
||||||
|
val tags =
|
||||||
|
arrayOf(
|
||||||
|
arrayOf(ConcordRekey.TAG_SCOPE, ConcordRekey.ROOT_SCOPE.toHexKey()),
|
||||||
|
arrayOf(ConcordRekey.TAG_NEWEPOCH, newEpoch.toString()),
|
||||||
|
arrayOf(ConcordRekey.TAG_PREVEPOCH, community.rootEpoch.toString()),
|
||||||
|
arrayOf(ConcordRekey.TAG_PREVCOMMIT, prevCommit),
|
||||||
|
arrayOf(ConcordRekey.TAG_CHUNK, "0", "1"),
|
||||||
|
)
|
||||||
|
val rumor = RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
|
||||||
|
val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey(community), owner, encrypted = true, createdAt = now)
|
||||||
|
assertNull(ConcordRefounding.findNewRoot(listOf(wrap), baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rekey seals must be encrypted (CORD-02 §5) ------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPlaintextSealedRekeyIsIgnored() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Test", now)
|
||||||
|
val encrypted = rotation(community, owner, ByteArray(32) { 1 }, listOf(member.pubKey))
|
||||||
|
val rumor = rumorsOf(encrypted, baseRekey(community)).single().rumor
|
||||||
|
val plaintext = ConcordStreamEnvelope.wrap(rumor, baseRekey(community), owner, encrypted = false, createdAt = now)
|
||||||
|
|
||||||
|
assertNotNull(ConcordRefounding.findNewRoot(encrypted, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch))
|
||||||
|
assertNull(ConcordRefounding.findNewRoot(listOf(plaintext), baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- I10: chunk by bytes so the wrap stays under NIP-44's 65,535-byte plaintext ---------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staffChunksStayUnderTheNip44Cap() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Test", now)
|
||||||
|
val recipients = members(300) + member.pubKey
|
||||||
|
// Every recipient staff: the widest (136-byte) blob. 120 of these overflowed the cap.
|
||||||
|
val wraps = rotation(community, owner, ByteArray(32) { 1 }, recipients, staff = recipients.toSet())
|
||||||
|
val opened = rumorsOf(wraps, baseRekey(community))
|
||||||
|
|
||||||
|
for (o in opened) {
|
||||||
|
assertTrue(
|
||||||
|
o.rumor
|
||||||
|
.toJson()
|
||||||
|
.encodeToByteArray()
|
||||||
|
.size <= ConcordRekey.REKEY_RUMOR_MAX_BYTES,
|
||||||
|
"rumor over the byte budget",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
o.seal
|
||||||
|
.toJson()
|
||||||
|
.encodeToByteArray()
|
||||||
|
.size <= 65_535,
|
||||||
|
"the wrap's NIP-44 plaintext (the seal) must fit the cap",
|
||||||
|
)
|
||||||
|
assertTrue(ConcordRekey.decodeContent(o.rumor.content).size <= 90, "the reference client fits 90 staff blobs per chunk")
|
||||||
|
}
|
||||||
|
assertEquals(recipients.size, opened.sumOf { ConcordRekey.decodeContent(it.rumor.content).size })
|
||||||
|
// And everyone still finds their key across the chunks.
|
||||||
|
assertNotNull(ConcordRefounding.findNewRoot(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun memberChunksStayUnderTheNip44Cap() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Test", now)
|
||||||
|
val wraps = rotation(community, owner, ByteArray(32) { 1 }, members(250))
|
||||||
|
for (o in rumorsOf(wraps, baseRekey(community))) {
|
||||||
|
assertTrue(
|
||||||
|
o.seal
|
||||||
|
.toJson()
|
||||||
|
.encodeToByteArray()
|
||||||
|
.size <= 65_535,
|
||||||
|
)
|
||||||
|
// ~99 per chunk (the reference client's figure; our slimmer rumor envelope fits a few more).
|
||||||
|
assertTrue(
|
||||||
|
o.rumor
|
||||||
|
.toJson()
|
||||||
|
.encodeToByteArray()
|
||||||
|
.size <= ConcordRekey.REKEY_RUMOR_MAX_BYTES,
|
||||||
|
)
|
||||||
|
assertTrue(ConcordRekey.decodeContent(o.rumor.content).size < ConcordRekey.MAX_BLOBS_PER_CHUNK, "the byte budget, not the count cap, binds for 104-byte blobs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chunkingKeepsTheCountCap() {
|
||||||
|
val tiny = List(300) { RekeyBlob("a", "b") }
|
||||||
|
val chunks = ConcordRekey.chunkBlobs(tiny, envelopeBytes = 300)
|
||||||
|
assertEquals(listOf(120, 120, 60), chunks.map { it.size })
|
||||||
|
assertEquals(listOf(0), ConcordRekey.chunkBlobs(emptyList(), 300).map { it.size })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- I9: the vac citation --------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyChunkCarriesTheRotatorsCitation() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Test", now)
|
||||||
|
val citation = AuthorityCitation(ByteArray(32) { 3 }, 4, ByteArray(32) { 5 })
|
||||||
|
val wraps = rotation(community, admin, ByteArray(32) { 1 }, members(250) + member.pubKey, authority = citation)
|
||||||
|
for (o in rumorsOf(wraps, baseRekey(community))) {
|
||||||
|
assertEquals(
|
||||||
|
VacTag.assemble(citation).toList(),
|
||||||
|
o.rumor.tags
|
||||||
|
.first { it[0] == VacTag.TAG_NAME }
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val got = ConcordRefounding.findNewRoot(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch)
|
||||||
|
assertNotNull(got)
|
||||||
|
assertEquals(admin.pubKey, got.rotator)
|
||||||
|
assertContentEquals(citation.grantId, got.authority?.grantId)
|
||||||
|
assertEquals(4L, got.authority?.grantVersion)
|
||||||
|
|
||||||
|
// The owner cites nothing.
|
||||||
|
val byOwner = rotation(community, owner, ByteArray(32) { 1 }, listOf(member.pubKey))
|
||||||
|
assertTrue(rumorsOf(byOwner, baseRekey(community)).all { o -> o.rumor.tags.none { it[0] == VacTag.TAG_NAME } })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun citationsAreVerifiedAgainstTheFoldedGrantHead() {
|
||||||
|
val cid = "11".repeat(32)
|
||||||
|
val ownerHex = owner.pubKey
|
||||||
|
val grantEid = ConcordKeyDerivation.grantCoordinate(cid.hexToByteArray(), admin.pubKey.hexToByteArray()).toHexKey()
|
||||||
|
val hash = "ab".repeat(32)
|
||||||
|
val heads = mapOf(grantEid to EntityFloor(3, hash))
|
||||||
|
|
||||||
|
val minted = ConcordRotationAuthority.citationFor(cid, admin.pubKey, ownerHex, heads)
|
||||||
|
assertNotNull(minted)
|
||||||
|
assertEquals(grantEid, minted.grantId.toHexKey())
|
||||||
|
assertEquals(3L, minted.grantVersion)
|
||||||
|
assertNull(ConcordRotationAuthority.citationFor(cid, ownerHex, ownerHex, heads), "the owner cites nothing")
|
||||||
|
|
||||||
|
fun ok(c: AuthorityCitation?) = ConcordRotationAuthority.citationSatisfied(cid, admin.pubKey, ownerHex, c, heads)
|
||||||
|
assertTrue(ok(minted))
|
||||||
|
assertTrue(ConcordRotationAuthority.citationSatisfied(cid, ownerHex, ownerHex, null, heads), "the owner needs no citation")
|
||||||
|
assertFalse(ok(null), "a delegated rotator must cite")
|
||||||
|
assertTrue(ok(AuthorityCitation(minted.grantId, 2, ByteArray(32))), "our head is newer than the cited Grant: rank decides")
|
||||||
|
assertFalse(ok(AuthorityCitation(minted.grantId, 4, ByteArray(32))), "cites a Grant we have not synced: park")
|
||||||
|
assertFalse(ok(AuthorityCitation(minted.grantId, 3, ByteArray(32) { 9 })), "same version, different hash: a fork")
|
||||||
|
val otherEid = ConcordKeyDerivation.grantCoordinate(cid.hexToByteArray(), member.pubKey.hexToByteArray())
|
||||||
|
assertFalse(ok(AuthorityCitation(otherEid, 3, hash.hexToByteArray())), "must cite the rotator's OWN Grant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- I12: race convergence + idempotent retry --------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun racingRotationsConvergeOnTheLowestAuthorizedRoot() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Test", now)
|
||||||
|
val high = ByteArray(32) { 0x5A }
|
||||||
|
val low = ByteArray(32) { 0x10 }
|
||||||
|
val wraps = rotation(community, owner, high, listOf(member.pubKey)) + rotation(community, admin, low, listOf(member.pubKey))
|
||||||
|
|
||||||
|
val all = ConcordRefounding.findNewRoots(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch)
|
||||||
|
assertEquals(2, all.size)
|
||||||
|
|
||||||
|
// Fetch order must not matter: every client picks the same winner.
|
||||||
|
for (order in listOf(wraps, wraps.reversed())) {
|
||||||
|
val won = ConcordRefounding.findNewRoot(order, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch)
|
||||||
|
assertNotNull(won)
|
||||||
|
assertContentEquals(low, won.newRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize before converging: an unauthorized lower root never wins.
|
||||||
|
val onlyOwner = ConcordRefounding.findNewRoot(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch) { it.rotator == owner.pubKey }
|
||||||
|
assertNotNull(onlyOwner)
|
||||||
|
assertContentEquals(high, onlyOwner.newRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theSameEpochHealIsDownOnly() {
|
||||||
|
val low = ByteArray(32) { 0x10 }
|
||||||
|
val high = ByteArray(32) { 0x5A }
|
||||||
|
assertTrue(ConcordRefounding.healsTo(held = high, candidate = low))
|
||||||
|
assertFalse(ConcordRefounding.healsTo(held = low, candidate = high), "a flaky fetch of the higher sibling must not re-fork")
|
||||||
|
assertFalse(ConcordRefounding.healsTo(held = low, candidate = low))
|
||||||
|
// Unsigned order: 0x80 sorts above 0x7F.
|
||||||
|
assertTrue(ConcordRefounding.compareKeys(byteArrayOf(0x7F), byteArrayOf(0x80.toByte())) < 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun losingForkRootsAreKeptButNotFoldedFrom() {
|
||||||
|
val held =
|
||||||
|
listOf(
|
||||||
|
HeldRoot(1, "5a".repeat(32), controlPk = null),
|
||||||
|
HeldRoot(1, "10".repeat(32), controlPk = "cc".repeat(32)),
|
||||||
|
HeldRoot(0, "aa".repeat(32)),
|
||||||
|
)
|
||||||
|
val canonical = ConcordRefounding.canonicalHeldRoots(held)
|
||||||
|
assertEquals(setOf(0L to "aa".repeat(32), 1L to "10".repeat(32)), canonical.map { it.epoch to it.key }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRetriedRefoundingReusesItsReservedKeys() {
|
||||||
|
val cid = "11".repeat(32)
|
||||||
|
val priorRoot = ByteArray(32) { 2 }
|
||||||
|
val first = ConcordRefounding.reserveKeys(null, cid, 3, priorRoot)
|
||||||
|
val retry = ConcordRefounding.reserveKeys(first, cid, 3, priorRoot)
|
||||||
|
assertSame(first, retry, "a retry must re-deliver the same root, never mint a sibling")
|
||||||
|
|
||||||
|
// A different rotation (another epoch, or another prior root) gets fresh keys.
|
||||||
|
val nextEpoch = ConcordRefounding.reserveKeys(first, cid, 4, priorRoot)
|
||||||
|
assertFalse(nextEpoch.newRoot.contentEquals(first.newRoot))
|
||||||
|
val otherRoot = ConcordRefounding.reserveKeys(first, cid, 3, ByteArray(32) { 9 })
|
||||||
|
assertFalse(otherRoot.newRoot.contentEquals(first.newRoot))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- S12: fold-all-or-abort compaction ---------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun compactionAbortsWhenAnHonoredHeadIsMissing() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Test", now, description = "A place")
|
||||||
|
val newControl = ControlPlaneKeys.forStaff(ByteArray(32) { 7 }, community.communityId, community.rootEpoch + 1, controlRoot)
|
||||||
|
val heads =
|
||||||
|
community.genesisWraps
|
||||||
|
.mapNotNull { ConcordStreamEnvelope.openOrNull(it, community.controlPlane)?.let { o -> ControlEdition.fromRumor(o.rumor) } }
|
||||||
|
.associate { it.entityIdHex to it.version }
|
||||||
|
|
||||||
|
// Everything we honor is present: the compaction goes ahead.
|
||||||
|
val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, owner.pubKey, mustCarry = heads)
|
||||||
|
assertEquals(heads.size, compacted.size)
|
||||||
|
|
||||||
|
// An entity we fold (or a newer version of one) that the fetched plane lacks: abort.
|
||||||
|
val missing =
|
||||||
|
assertFailsWith<IncompleteControlPlaneException> {
|
||||||
|
ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, owner.pubKey, mustCarry = heads + ("ff".repeat(32) to 0L))
|
||||||
|
}
|
||||||
|
assertEquals(listOf("ff".repeat(32)), missing.missing)
|
||||||
|
val first = heads.keys.first()
|
||||||
|
assertFailsWith<IncompleteControlPlaneException> {
|
||||||
|
ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, owner.pubKey, mustCarry = mapOf(first to heads.getValue(first) + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -194,7 +194,7 @@ class ControlRootRotationTest {
|
|||||||
|
|
||||||
val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch)
|
val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch)
|
||||||
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey()
|
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey()
|
||||||
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 0, 1)
|
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 1, 1)
|
||||||
val rumor =
|
val rumor =
|
||||||
RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
|
RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
|
||||||
val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now)
|
val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now)
|
||||||
@@ -224,7 +224,7 @@ class ControlRootRotationTest {
|
|||||||
|
|
||||||
val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch)
|
val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch)
|
||||||
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey()
|
val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey()
|
||||||
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 0, 1)
|
val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 1, 1)
|
||||||
val rumor =
|
val rumor =
|
||||||
RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
|
RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
|
||||||
val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now)
|
val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now)
|
||||||
|
|||||||
Reference in New Issue
Block a user