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 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 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 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 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. |
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
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.HeldRoot
|
||||
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.ConcordInviteListTombstone
|
||||
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.nip01Core.core.hexToByteArray
|
||||
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 rekey [COMMUNITY] follow a Refounding we were re-keyed for:
|
||||
| open our blob and adopt the new epoch
|
||||
| concord recover [COMMUNITY] re-resolve the joined-through invite link and
|
||||
| follow a Refounding we were left out of
|
||||
| (CORD-06); refuses if that epoch banned us
|
||||
| concord recover [COMMUNITY] [--rejoin] re-resolve the joined-through invite link and
|
||||
| report whether a Refounding left us behind;
|
||||
| --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 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
|
||||
@@ -285,7 +289,9 @@ object ConcordCommands {
|
||||
ctx.prepare()
|
||||
// The joiner cannot derive the Control Plane address, so the invite carries it
|
||||
// (CORD-05 §1); omitted for a legacy community, which has none to carry.
|
||||
val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null })
|
||||
// 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)
|
||||
// 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
|
||||
@@ -433,7 +439,7 @@ object ConcordCommands {
|
||||
// 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.
|
||||
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.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")
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
ConcordStore(dataDir.concordFile).upsert(
|
||||
val stored =
|
||||
StoredCommunity(
|
||||
name = bundle.name,
|
||||
communityId = bundle.communityId,
|
||||
@@ -485,15 +491,53 @@ object ConcordCommands {
|
||||
// this link is the only way back (CORD-05/06). Stored bare, domain-agnostic.
|
||||
inviteRef = ConcordActions.bareInviteRef(url) ?: "",
|
||||
privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
|
||||
),
|
||||
)
|
||||
Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays))
|
||||
)
|
||||
ConcordStore(dataDir.concordFile).upsert(stored)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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 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
|
||||
* member simply left out of the rekey receives nothing and sits on the dead epoch forever while
|
||||
* everyone else moves on. There is no message to miss, which is why the rekey drain cannot help.
|
||||
* The way back is the invite link the membership was joined through: the community keeps
|
||||
* re-minting its bundle at the same addressable coordinate, so a live bundle at a **strictly
|
||||
* higher** epoch than ours proves we were left behind — and carries the new root.
|
||||
* member simply left out of the rekey receives nothing and sits on the dead epoch while
|
||||
* everyone else moves on. The invite link the membership was joined through is re-minted at
|
||||
* the current epoch, so a live bundle there at a **strictly higher** epoch says we were left
|
||||
* behind.
|
||||
*
|
||||
* Amethyst sweeps this on a timer; amy makes it an explicit verb, so it stays deterministic and
|
||||
* scriptable rather than a background loop.
|
||||
* A bundle is NOT proof of continuity (nothing binds `community_root` to `community_id`), so
|
||||
* 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
|
||||
* without it this walks them straight back into the epoch they were rotated out of. It reads the
|
||||
* 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.
|
||||
* Ban-gated at the epoch we are leaving and **fails closed** (no fold, no verdict, no rejoin);
|
||||
* a dissolved community is never moved (CORD-02 §9).
|
||||
*/
|
||||
private suspend fun recover(
|
||||
dataDir: DataDir,
|
||||
@@ -601,6 +643,7 @@ object ConcordCommands {
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positionalOrNull(0)
|
||||
val rejoin = args.bool("rejoin")
|
||||
args.rejectUnknown()
|
||||
val store = ConcordStore(dataDir.concordFile)
|
||||
val targets =
|
||||
@@ -614,54 +657,68 @@ object ConcordCommands {
|
||||
ctx.prepare()
|
||||
val results = mutableListOf<Map<String, Any?>>()
|
||||
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 }
|
||||
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
|
||||
}
|
||||
val parsed = ConcordActions.parseInviteLink(inviteRef)
|
||||
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
|
||||
}
|
||||
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 }
|
||||
// Only a LIVE bundle recovers: an expired or revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite
|
||||
// Only a LIVE bundle counts: an expired or revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite
|
||||
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
|
||||
}
|
||||
|
||||
// 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".
|
||||
// Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict.
|
||||
val cp = controlPlaneKeysFor(sc)
|
||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
|
||||
val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val editions = ConcordActions.controlEditions(controlWraps, cp)
|
||||
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
|
||||
}
|
||||
val bannedHere = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner).isBanned(ctx.signer.pubKey)
|
||||
|
||||
val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere)
|
||||
if (merged == null) {
|
||||
val entry = entryFor(sc)
|
||||
if (!ConcordActions.isStranded(entry, bundle, bannedHere)) {
|
||||
results += skip(if (bannedHere) "banned" else "already_current") + ("root_epoch" to sc.rootEpoch)
|
||||
continue
|
||||
}
|
||||
if (!rejoin) {
|
||||
results +=
|
||||
mapOf(
|
||||
"community_id" to sc.communityId,
|
||||
"name" to sc.name,
|
||||
"stranded" to true,
|
||||
"recovered" to false,
|
||||
"reason" to if (bannedHere) "banned" else "already_current",
|
||||
"reason" to "rejoin_required",
|
||||
"root_epoch" to sc.rootEpoch,
|
||||
"link_epoch" to bundle.rootEpoch,
|
||||
)
|
||||
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 +=
|
||||
mapOf(
|
||||
"community_id" to sc.communityId,
|
||||
"name" to sc.name,
|
||||
"stranded" to true,
|
||||
"recovered" to true,
|
||||
"from_epoch" to sc.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.
|
||||
*
|
||||
* 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:
|
||||
* a plane that will not fold yields no verdict and the community is skipped.
|
||||
* never `effectivePermissions`, so a banned BAN-holder cannot rotate us — and must cite the
|
||||
* 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(
|
||||
dataDir: DataDir,
|
||||
@@ -698,21 +758,12 @@ object ConcordCommands {
|
||||
ctx.prepare()
|
||||
val results = mutableListOf<Map<String, Any?>>()
|
||||
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 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)
|
||||
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.
|
||||
// Authorize against the epoch we are LEAVING — the last plane we can fold.
|
||||
val cp = controlPlaneKeysFor(sc)
|
||||
ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey))
|
||||
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")
|
||||
continue
|
||||
}
|
||||
if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner), received.rotator)) {
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator)
|
||||
val entry = entryFor(sc)
|
||||
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
|
||||
}
|
||||
val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
||||
store.upsert(storedFrom(sc, adopted))
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
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.stores.ConcordStore
|
||||
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.ConcordModeration
|
||||
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.RoleEntity
|
||||
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.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -286,6 +290,11 @@ object ConcordModCommands {
|
||||
.toSet()
|
||||
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 (cp, editions) = loaded
|
||||
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).
|
||||
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)
|
||||
|
||||
// 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
|
||||
// new epoch — carries the ban. Each edition chains onto the updated banlist head.
|
||||
var chain = editions
|
||||
@@ -334,45 +357,67 @@ object ConcordModCommands {
|
||||
|
||||
// 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).
|
||||
val newRoot = RandomInstance.bytes(32)
|
||||
val newControlRoot = RandomInstance.bytes(32)
|
||||
// Compact from what we KNOW the plane holds: the wraps we drained plus the bans we just
|
||||
// The keys are RESERVED and persisted before anything is published, so a retried
|
||||
// `refound` re-delivers the same root instead of minting a sibling (CORD-06 §3).
|
||||
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
|
||||
// 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.
|
||||
val drained = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
val controlWraps = (drained + banWraps).distinctBy { it.id }
|
||||
val controlWraps = (swept + banWraps).distinctBy { it.id }
|
||||
val build =
|
||||
ConcordActions.buildRefounding(
|
||||
rotatorSigner = ctx.signer,
|
||||
communityId = sc.communityId,
|
||||
priorRoot = sc.root.hexToByteArray(),
|
||||
newRoot = newRoot,
|
||||
newControlRoot = newControlRoot,
|
||||
rootEpoch = sc.rootEpoch,
|
||||
priorControlWraps = controlWraps,
|
||||
priorControlKeys = cp,
|
||||
recipientsXOnly = recipients,
|
||||
staffXOnly = authority.staffMembers(),
|
||||
createdAt = TimeUtils.now(),
|
||||
ownerPubKey = sc.owner,
|
||||
)
|
||||
try {
|
||||
ConcordActions.buildRefounding(
|
||||
rotatorSigner = ctx.signer,
|
||||
communityId = sc.communityId,
|
||||
priorRoot = priorRoot,
|
||||
newRoot = keys.newRoot,
|
||||
newControlRoot = keys.newControlRoot,
|
||||
rootEpoch = sc.rootEpoch,
|
||||
priorControlWraps = controlWraps,
|
||||
priorControlKeys = cp,
|
||||
recipientsXOnly = recipients,
|
||||
staffXOnly = authority.staffMembers(),
|
||||
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).
|
||||
build.controlWraps.forEach { ctx.publish(it, relays) }
|
||||
build.rekeyWraps.forEach { ctx.publish(it, relays) }
|
||||
// 4. The root roll FIRST, every chunk confirmed; the compacted plane only after it
|
||||
// (CORD-06 §3). A chunk no relay took aborts with nothing adopted — the reserved keys
|
||||
// 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
|
||||
// epoch we are leaving for the anti-rollback floor.
|
||||
// epoch we are leaving for the anti-rollback floor — and drop the reservation.
|
||||
val adopted =
|
||||
ConcordReceive.withAdoptedRoot(
|
||||
ConcordCommands.entryFor(loaded.community),
|
||||
newRoot,
|
||||
keys.newRoot,
|
||||
build.newEpoch,
|
||||
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)
|
||||
|
||||
// 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
|
||||
// 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 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 =
|
||||
live.invite.copy(
|
||||
communityRoot = stored.root,
|
||||
@@ -422,6 +467,7 @@ object ConcordModCommands {
|
||||
"recipients" to recipients.size,
|
||||
"control_wraps" to build.controlWraps.size,
|
||||
"rekey_wraps" to build.rekeyWraps.size,
|
||||
"compaction_failures" to compactionFailures,
|
||||
"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
|
||||
// is unreadable and `send` refuses rather than fall back to the root-derived plane.
|
||||
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`. */
|
||||
@@ -67,6 +70,14 @@ data class StoredPrivateChannel(
|
||||
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`. */
|
||||
data class StoredHeldRoot(
|
||||
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.cord03Channels.ChannelChat
|
||||
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.cord05Invites.CommunityInvite
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite
|
||||
@@ -281,6 +282,19 @@ object ConcordActions {
|
||||
rootEpoch: Long,
|
||||
): 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) -----------------------------------------
|
||||
|
||||
/** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */
|
||||
@@ -551,6 +565,8 @@ object ConcordActions {
|
||||
name: String,
|
||||
relays: List<String>,
|
||||
controlPk: HexKey? = null,
|
||||
creator: HexKey? = null,
|
||||
label: String? = null,
|
||||
): CommunityInvite =
|
||||
CommunityInvite(
|
||||
communityId = communityIdHex,
|
||||
@@ -561,6 +577,10 @@ object ConcordActions {
|
||||
controlPk = controlPk,
|
||||
relays = relays,
|
||||
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]). */
|
||||
@@ -620,15 +640,25 @@ object ConcordActions {
|
||||
fun bareInviteRef(url: String): String? = ConcordInviteLink.bareForm(url)
|
||||
|
||||
/**
|
||||
* Merges a stranded membership forward onto a higher-epoch [bundle] resolved at
|
||||
* its own stored invite link, or null when there is nothing to recover. See
|
||||
* [ConcordStrandedRecovery].
|
||||
* True when a live [bundle] resolved at [entry]'s own stored invite link says a Refounding
|
||||
* left us behind (a higher epoch, and we are not banned). Detection only: a bundle may never
|
||||
* move a held community's base on its own (CORD-06 §2) — see [ConcordStrandedRecovery].
|
||||
*/
|
||||
fun recoverStranded(
|
||||
fun isStranded(
|
||||
entry: ConcordCommunityListEntry,
|
||||
bundle: CommunityInvite,
|
||||
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. */
|
||||
fun openBundle(
|
||||
@@ -641,13 +671,15 @@ object ConcordActions {
|
||||
* [InviteBundleStatus] (live / expired / revoked / unreadable / absent) per CORD-05
|
||||
* §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
|
||||
* 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(
|
||||
wraps: List<Event>,
|
||||
linkSignerPubKey: HexKey,
|
||||
token: ByteArray,
|
||||
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
|
||||
@@ -735,6 +767,8 @@ object ConcordActions {
|
||||
staffXOnly: Set<HexKey>,
|
||||
createdAt: Long,
|
||||
ownerPubKey: HexKey,
|
||||
authority: AuthorityCitation? = null,
|
||||
mustCarry: Map<String, Long> = emptyMap(),
|
||||
): RefoundingBuild =
|
||||
ConcordRefounding.build(
|
||||
rotatorSigner = rotatorSigner,
|
||||
@@ -749,6 +783,8 @@ object ConcordActions {
|
||||
staffXOnly = staffXOnly,
|
||||
createdAt = createdAt,
|
||||
ownerPubKey = ownerPubKey,
|
||||
authority = authority,
|
||||
mustCarry = mustCarry,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -757,7 +793,9 @@ object ConcordActions {
|
||||
* scope, epoch and continuity against the [priorRoot] the member holds — and,
|
||||
* on a staff blob, that the delivered `control_root` derives to the delivered
|
||||
* `control_pk` (CORD-06 §1). Returns the new root + Control keys + rotator
|
||||
* (for the caller to authorize) or null if not re-keyed.
|
||||
* 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(
|
||||
wraps: List<Event>,
|
||||
@@ -766,5 +804,6 @@ object ConcordActions {
|
||||
communityId: HexKey,
|
||||
priorRoot: ByteArray,
|
||||
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.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
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.ControlRootWrap
|
||||
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.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -106,6 +110,71 @@ object ConcordReceive {
|
||||
rotator: HexKey,
|
||||
): 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
|
||||
* caller can diff, persist and publish it however its platform does.
|
||||
@@ -133,7 +202,9 @@ object ConcordReceive {
|
||||
rootEpoch = newEpoch,
|
||||
controlPk = newControlPk?.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,
|
||||
relays = entry.relays,
|
||||
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.ConcordDissolution
|
||||
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.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -78,7 +79,9 @@ object ConcordSubscriptionPlanner {
|
||||
// the entry, per epoch, exactly as it was delivered.
|
||||
val cp = ConcordActions.controlPlaneKeysFor(e)
|
||||
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 }
|
||||
.sortedByDescending { it.epoch }
|
||||
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
|
||||
@@ -104,10 +107,13 @@ object ConcordSubscriptionPlanner {
|
||||
val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch)
|
||||
val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch)
|
||||
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 = nextRekey.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.InviteBundleStatus
|
||||
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.GroupKey
|
||||
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.hexToByteArray
|
||||
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.fetchAllPages
|
||||
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.publishAndConfirm
|
||||
@@ -83,6 +89,9 @@ import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
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". */
|
||||
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
|
||||
|
||||
/** 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/
|
||||
* invite flows, channel messages/reactions/edits/typing, roles and moderation,
|
||||
@@ -332,7 +344,7 @@ class AccountConcordActions(
|
||||
val token = link.token.hexToByteArray()
|
||||
// Classify per coordinate, never over the pooled set: one link's newer
|
||||
// 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 =
|
||||
current.invite.copy(
|
||||
communityRoot = entry.root,
|
||||
@@ -390,7 +402,11 @@ class AccountConcordActions(
|
||||
// The joiner can never derive the Control Plane address, so the bundle carries
|
||||
// it (CORD-05 §1). Null on a legacy community, which has none to carry.
|
||||
controlPk = entry.controlPk,
|
||||
// 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 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
|
||||
// instead of stranding them on a spinner that retries a link we can never redeem.
|
||||
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.Expired -> return ConcordInviteResult.Expired
|
||||
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
|
||||
// old invite is reopened, so short-circuit to Joined — the screen forwards to the community
|
||||
// either way ("take me there", not "join again").
|
||||
if (account.concordChannelList.liveCommunities.value
|
||||
.any { it.id == bundle.communityId }
|
||||
) {
|
||||
return ConcordInviteResult.Joined(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
|
||||
// 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
|
||||
@@ -589,6 +620,19 @@ class AccountConcordActions(
|
||||
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 =
|
||||
ConcordCommunityListEntry(
|
||||
id = bundle.communityId,
|
||||
@@ -610,7 +654,7 @@ class AccountConcordActions(
|
||||
// recoverStrandedConcordCommunities().
|
||||
inviteRef = ConcordActions.bareInviteRef(url),
|
||||
)
|
||||
joinConcordCommunity(entry)
|
||||
joinConcordCommunity(entry, inviteCreator, inviteLabel)
|
||||
return ConcordInviteResult.Joined(bundle.communityId)
|
||||
}
|
||||
|
||||
@@ -1200,6 +1244,12 @@ class AccountConcordActions(
|
||||
if (!account.isWriteable()) return false
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: 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
|
||||
// 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
|
||||
@@ -1221,6 +1271,19 @@ class AccountConcordActions(
|
||||
// compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A
|
||||
// rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery.
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
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 —
|
||||
// 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
|
||||
// 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 =
|
||||
(session.allMembers() + account.signer.pubKey)
|
||||
.mapTo(HashSet()) { it.lowercase() }
|
||||
@@ -1250,52 +1313,113 @@ class AccountConcordActions(
|
||||
}.let { candidates -> boundRecipients(candidates, authority) }
|
||||
|
||||
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
|
||||
val entry = session.entry
|
||||
val newRoot = RandomInstance.bytes(32)
|
||||
// A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2),
|
||||
// so a demoted staffer's retained secret dies with the epoch — and a legacy community
|
||||
// upgrades to the split as a side effect of its next ban (CORD-06 §3).
|
||||
val newControlRoot = RandomInstance.bytes(32)
|
||||
// The keys are RESERVED per (epoch, prior root): a retry after a failed publish re-delivers
|
||||
// the same root instead of minting a sibling that would split the members (CORD-06 §3).
|
||||
// A fresh control_root rides beside the new root at every Refounding (CORD-02 §2), so a
|
||||
// demoted staffer's retained secret dies with the epoch — and a legacy community upgrades
|
||||
// to the split as a side effect of its next ban (CORD-06 §3).
|
||||
val 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
|
||||
// Control-writing bit (CORD-04 §3). They get the 136-byte blob, every other
|
||||
// recipient the 104-byte one carrying the pubkey alone. (The builder mints a
|
||||
// blob per recipient, so staff who aren't recipients are simply never reached.)
|
||||
val staff = authority.staffMembers()
|
||||
val build =
|
||||
ConcordActions.buildRefounding(
|
||||
rotatorSigner = account.signer,
|
||||
communityId = communityId,
|
||||
priorRoot = entry.root.hexToByteArray(),
|
||||
newRoot = newRoot,
|
||||
newControlRoot = newControlRoot,
|
||||
rootEpoch = entry.rootEpoch,
|
||||
priorControlWraps = session.controlPlaneWraps(),
|
||||
priorControlKeys = cp,
|
||||
recipientsXOnly = recipients,
|
||||
staffXOnly = staff,
|
||||
createdAt = TimeUtils.now(),
|
||||
ownerPubKey = entry.owner,
|
||||
)
|
||||
try {
|
||||
ConcordActions.buildRefounding(
|
||||
rotatorSigner = account.signer,
|
||||
communityId = communityId,
|
||||
priorRoot = priorRoot,
|
||||
newRoot = keys.newRoot,
|
||||
newControlRoot = keys.newControlRoot,
|
||||
rootEpoch = entry.rootEpoch,
|
||||
priorControlWraps = (session.controlPlaneWraps() + swept).distinctBy { it.id },
|
||||
priorControlKeys = cp,
|
||||
recipientsXOnly = recipients,
|
||||
staffXOnly = staff,
|
||||
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
|
||||
// (the key that unlocks it) to the community relays.
|
||||
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
if (publishTo.isNotEmpty()) {
|
||||
build.controlWraps.forEach { account.client.publish(it, publishTo) }
|
||||
build.rekeyWraps.forEach { account.client.publish(it, publishTo) }
|
||||
// 4. The root roll FIRST, each chunk confirmed (CORD-06 §3): the compacted plane is
|
||||
// republished only after the rekey blobs are known to have landed. A chunk no relay
|
||||
// accepted aborts here with nothing adopted; the reserved keys make the retry idempotent.
|
||||
for (wrap in build.rekeyWraps) {
|
||||
if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) {
|
||||
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
|
||||
// re-folds the compacted Control Plane (with the ban), dropping the removed members.
|
||||
val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
|
||||
// 5. The compacted Control Plane, at the new epoch's address. The root roll is committed, so
|
||||
// a head that fails to land is reported, not rolled back — members already hold the new
|
||||
// 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.
|
||||
// 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.
|
||||
val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0
|
||||
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,
|
||||
* 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
|
||||
* cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves
|
||||
* the invite link the membership was joined through and merges forward.
|
||||
* owner included) by omission — nothing on this receive path can prevent it.
|
||||
* [recoverStrandedConcordCommunities] detects it; re-opening the invite link rejoins.
|
||||
*
|
||||
* 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() {
|
||||
if (!account.isWriteable()) return
|
||||
for (session in account.concordSessions.sessions()) {
|
||||
val wraps = session.pendingBaseRekeyWraps()
|
||||
if (wraps.isEmpty()) continue
|
||||
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(
|
||||
wraps = wraps,
|
||||
baseRekey = session.nextBaseRekeyKey(),
|
||||
wraps = siblingWraps,
|
||||
baseRekey = siblingKey,
|
||||
recipientSigner = account.signer,
|
||||
communityId = entry.id,
|
||||
priorRoot = entry.root.hexToByteArray(),
|
||||
rootEpoch = entry.rootEpoch,
|
||||
priorRoot = prior.key.hexToByteArray(),
|
||||
rootEpoch = prior.epoch,
|
||||
accept = honored,
|
||||
) ?: continue
|
||||
if (received.newEpoch <= entry.rootEpoch) continue
|
||||
val authority = session.state.value?.authority ?: continue
|
||||
|
||||
// hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder
|
||||
// who has themselves been banned could still rotate the whole community.
|
||||
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
|
||||
if (!authorized) continue
|
||||
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}" }
|
||||
}
|
||||
val healed = ConcordReceive.withHealedRoot(entry, sibling) ?: 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" }
|
||||
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(healed))
|
||||
announceConcordGuestbookJoin(healed, inviteCreator = null, inviteLabel = null)
|
||||
refreshConcordInviteLinks(healed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// Concord revision tick (which fires on every structural change) without turning it into a
|
||||
// relay-fetch loop.
|
||||
@@ -1475,20 +1643,18 @@ class AccountConcordActions(
|
||||
* included, and [drainConcordRekeys] cannot prevent it: there is no message to
|
||||
* 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
|
||||
* carried through every rotation by [adoptConcordRoot]). The community keeps
|
||||
* 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
|
||||
* the new root. Same or lower epoch is a no-op. Memberships with no link (direct
|
||||
* invites, legacy entries) are inert here; that is expected, not an error.
|
||||
* a **strictly higher** epoch than ours says we were left behind. Memberships with
|
||||
* no link (direct invites, legacy entries) are inert here.
|
||||
*
|
||||
* The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps
|
||||
* both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the
|
||||
* entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays
|
||||
* derivable). We then re-announce the Guestbook at the new epoch, exactly as an
|
||||
* ordinary rotation does, so the recovered member is visible to whoever refounds
|
||||
* next instead of being silently dropped again.
|
||||
* Detection ONLY ([strandedConcordCommunities]). The bundle is not proof of
|
||||
* continuity — nothing binds `community_root` to `community_id` — so adopting its
|
||||
* root here would let any link creator relocate every member who joined through
|
||||
* their link (CORD-06 §2: the base advances only by a verifiable rekey). The way
|
||||
* forward is the user explicitly re-opening the link ([joinConcordViaInvite]).
|
||||
*
|
||||
* 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.
|
||||
@@ -1502,6 +1668,21 @@ class AccountConcordActions(
|
||||
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
|
||||
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 relays =
|
||||
(
|
||||
@@ -1512,39 +1693,14 @@ class AccountConcordActions(
|
||||
|
||||
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
|
||||
val wraps = account.client.fetchAll(filters = filters)
|
||||
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
|
||||
// Only a live bundle counts: an expired/revoked link is not a rotation we missed.
|
||||
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
|
||||
// walks them straight back into the epoch they were rotated out of — see A2 in
|
||||
// docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last
|
||||
// one whose Control Plane we can still fold.
|
||||
//
|
||||
// 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)
|
||||
// Detection only (CORD-06 §2): the bundle is not proof of continuity, so it never moves
|
||||
// our base. The user re-accepting the link is the way forward (joinConcordViaInvite).
|
||||
val stranded = ConcordActions.isStranded(entry, bundle, state.authority.isBanned(account.signer.pubKey))
|
||||
_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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1671,7 +1827,13 @@ class AccountConcordActions(
|
||||
if (relays.isEmpty()) return null
|
||||
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
|
||||
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.EditionFold
|
||||
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.GroupKey
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
@@ -139,6 +140,13 @@ class ConcordCommunitySession(
|
||||
*/
|
||||
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
|
||||
* 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. */
|
||||
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 ->
|
||||
* 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.
|
||||
*/
|
||||
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 }
|
||||
.sortedByDescending { it.epoch }
|
||||
.take(ConcordActions.MAX_BACKFILL_EPOCHS)
|
||||
@@ -239,6 +252,7 @@ class ConcordCommunitySession(
|
||||
private val channelWrapsById = HashMap<HexKey, LinkedHashMap<HexKey, Event>>() // channelIdHex -> (wrapId -> wrap)
|
||||
private val guestbookWraps = 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
|
||||
// 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 == guestbookAddress ||
|
||||
address == nextBaseRekeyAddress ||
|
||||
address == siblingBaseRekeyAddress ||
|
||||
address == dissolvedAddress ||
|
||||
address in historicalControlKeys ||
|
||||
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. */
|
||||
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
|
||||
* 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)
|
||||
* 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. */
|
||||
fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) }
|
||||
@@ -542,6 +563,11 @@ class ConcordCommunitySession(
|
||||
lock.withLock { baseRekeyWraps[wrap.id] = wrap }
|
||||
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 -> {
|
||||
// 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
|
||||
|
||||
+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 |
|
||||
| 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 |
|
||||
| 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)` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | 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 | 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 | **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 | **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 | **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)
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| I8 | 06 | Rekey `chunk` index is 0-based; Armada requires 1-based and drops all our Refoundings | open → rekey/invite batch |
|
||||
| I9 | 06 §3 | Rotations carry no `vac` | open → rekey/invite batch |
|
||||
| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | open → rekey/invite batch |
|
||||
| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | open → rekey/invite batch |
|
||||
| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | 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` | **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 | **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 | **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 | **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` |
|
||||
| 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`) |
|
||||
| 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` |
|
||||
| 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
|
||||
|
||||
@@ -95,6 +95,12 @@ Ranked security > interop > feature inside each group.
|
||||
|
||||
## 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
|
||||
such payload, and Armada has none. Dangling reference.
|
||||
- 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
|
||||
val rumor = seal.unsealOrNull(recipientSigner) ?: 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.crypto.ConcordKeyDerivation
|
||||
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.toHexKey
|
||||
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.nip44Encryption.Nip44
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
@@ -94,6 +96,52 @@ class MintedInviteLink(
|
||||
object ConcordInviteBundle {
|
||||
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)
|
||||
|
||||
/** Builds a kind-33301 bundle event carrying [invite], encrypted under [token] and signed by [linkSignerPrivKey]. */
|
||||
@@ -125,7 +173,10 @@ object ConcordInviteBundle {
|
||||
createdAt: Long,
|
||||
): 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(
|
||||
event: Event,
|
||||
token: ByteArray,
|
||||
@@ -133,7 +184,7 @@ object ConcordInviteBundle {
|
||||
if (event.kind != KIND) return null
|
||||
return try {
|
||||
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) {
|
||||
null
|
||||
}
|
||||
@@ -152,11 +203,25 @@ object ConcordInviteBundle {
|
||||
* milliseconds) resolves to [InviteBundleStatus.Expired] rather than
|
||||
* [InviteBundleStatus.Live], so the expiry is actually enforced at the one place
|
||||
* 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(
|
||||
wraps: List<Event>,
|
||||
linkSignerPubKey: HexKey,
|
||||
token: ByteArray,
|
||||
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 {
|
||||
val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent
|
||||
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.
|
||||
*/
|
||||
fun validate(invite: CommunityInvite): Boolean {
|
||||
if (invite.channels.size > MAX_BUNDLE_CHANNELS) return false
|
||||
val owner = invite.owner.hexToByteArrayOrNull() ?: return false
|
||||
val salt = invite.ownerSalt.hexToByteArrayOrNull() ?: return false
|
||||
return ConcordKeyDerivation.communityId(owner, salt).toHexKey() == invite.communityId
|
||||
|
||||
+22
-14
@@ -62,6 +62,13 @@ object ConcordInviteLink {
|
||||
const val VERSION = 4
|
||||
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_FULL_URL = 255
|
||||
private const val WSS_PREFIX = "wss://"
|
||||
@@ -69,13 +76,10 @@ object ConcordInviteLink {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* relay is encoded (dictionary id, `wss://` host, or full URL).
|
||||
*
|
||||
* The only ceiling is the format's own: the relay count is a single byte, so at
|
||||
* 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.
|
||||
* exact stock set uses flag `0x01` and emits no relay bytes (the stock set is
|
||||
* flag-selected, so exempt from the cap); otherwise the first
|
||||
* [MAX_BOOTSTRAP_RELAYS] relays are encoded (dictionary id, `wss://` host, or full
|
||||
* URL) and the rest dropped (CORD-05 §3) — the bundle carries the full set.
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
fun encodeFragment(
|
||||
@@ -90,10 +94,10 @@ object ConcordInviteLink {
|
||||
if (useStock) {
|
||||
out.add(FLAG_STOCK_RELAYS.toByte())
|
||||
} 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(relays.size.toByte())
|
||||
for (r in relays) {
|
||||
out.add(bounded.size.toByte())
|
||||
for (r in bounded) {
|
||||
val id = InviteRelayDictionary.idOf(r)
|
||||
when {
|
||||
id != null -> out.add(id.toByte())
|
||||
@@ -119,8 +123,9 @@ object ConcordInviteLink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes an invite [fragment]. Throws for a malformed fragment or a version
|
||||
* other than [VERSION] (lower = legacy, higher = newer than this client).
|
||||
* Decodes an invite [fragment]. Throws for a malformed fragment, a version
|
||||
* 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.
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
@@ -138,7 +143,9 @@ object ConcordInviteLink {
|
||||
relays.addAll(InviteRelayDictionary.STOCK)
|
||||
usedStock = true
|
||||
} else {
|
||||
require(pos < bytes.size) { "fragment truncated" }
|
||||
val count = bytes[pos++].toInt() and 0xFF
|
||||
require(count <= MAX_BOOTSTRAP_RELAYS) { "too many bootstrap relays ($count, cap $MAX_BOOTSTRAP_RELAYS)" }
|
||||
repeat(count) {
|
||||
val marker = bytes[pos++].toInt() and 0xFF
|
||||
when (marker) {
|
||||
@@ -162,8 +169,9 @@ object ConcordInviteLink {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a full shareable invite URL under [base], carrying every relay in [relays]
|
||||
* as the bootstrap set (or the stock flag when null / exactly the stock set).
|
||||
* Builds a full shareable invite URL under [base], carrying the first
|
||||
* [MAX_BOOTSTRAP_RELAYS] of [relays] as the bootstrap set (or the stock flag when
|
||||
* null / exactly the stock set).
|
||||
*/
|
||||
fun buildUrl(
|
||||
base: String,
|
||||
|
||||
+36
-14
@@ -33,6 +33,7 @@ import kotlinx.serialization.descriptors.elementNames
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonTransformingSerializer
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
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
|
||||
* 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).
|
||||
*
|
||||
* [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(
|
||||
val entries: List<ConcordInviteListEntry> = emptyList(),
|
||||
val tombstones: List<ConcordInviteListTombstone> = emptyList(),
|
||||
val residue: JsonObject = NoExtras,
|
||||
val opaqueEntries: List<JsonObject> = emptyList(),
|
||||
val opaqueTombstones: List<JsonElement> = emptyList(),
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = ConcordInviteListDocument()
|
||||
@@ -201,14 +206,16 @@ object ConcordInviteList {
|
||||
}
|
||||
}
|
||||
|
||||
val opaqueTombstones = mutableListOf<JsonElement>()
|
||||
val tombstones =
|
||||
(root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element ->
|
||||
try {
|
||||
val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject)
|
||||
ConcordInviteListTombstone(it.token, it.communityId, it.extras)
|
||||
} catch (_: Exception) {
|
||||
// A tombstone we cannot read must not silently un-retire its link, but we
|
||||
// have no token to key it by, so it can only ride along as document residue.
|
||||
// A tombstone we cannot type must not silently un-retire its link: carry it
|
||||
// verbatim as residue (and still honor its token in the merge, if it has one).
|
||||
opaqueTombstones.add(element)
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -218,6 +225,7 @@ object ConcordInviteList {
|
||||
tombstones = tombstones,
|
||||
residue = JsonObject(root - "entries" - "tombstones"),
|
||||
opaqueEntries = opaque,
|
||||
opaqueTombstones = opaqueTombstones,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
@@ -238,30 +246,43 @@ object ConcordInviteList {
|
||||
),
|
||||
).jsonObject
|
||||
|
||||
// Entries we could not type ride back out untouched. Dropping them here is the data loss
|
||||
// this whole class exists to prevent — they are somebody's link signer too.
|
||||
if (doc.opaqueEntries.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire)
|
||||
val entries = JsonArray((wire["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries)
|
||||
return ConcordJson.instance.encodeToString(JsonObject.serializer(), JsonObject(wire + ("entries" to entries)))
|
||||
// Entries and tombstones we could not type ride back out untouched. Dropping them here is
|
||||
// the data loss this whole class exists to prevent — a link signer, or a link's retirement.
|
||||
if (doc.opaqueEntries.isEmpty() && doc.opaqueTombstones.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire)
|
||||
var out = wire
|
||||
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
|
||||
* either side's tombstones is dropped from the result and kept tombstoned, so a retired link
|
||||
* cannot be resurrected by a device that still has it cached. [patch] wins field-by-field on a
|
||||
* token both sides carry, which is what makes "read remote, apply my change, publish" converge.
|
||||
* Merges [patch] onto [base], keyed by `token` — the spec's own merge key (CORD-05 §4). An entry
|
||||
* is **immutable once minted**, so the first copy of a token wins ([base], the published list,
|
||||
* before [patch]) and a later copy can never rewrite its `signer_sk` or url; tombstones union
|
||||
* (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(
|
||||
base: ConcordInviteListDocument,
|
||||
patch: ConcordInviteListDocument,
|
||||
): ConcordInviteListDocument {
|
||||
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>()
|
||||
for (e in base.entries + patch.entries) {
|
||||
if (e.token in tombstones) continue
|
||||
entries[e.token] = e
|
||||
if (e.token in retired) continue
|
||||
entries.getOrPut(e.token) { e }
|
||||
}
|
||||
return ConcordInviteListDocument(
|
||||
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
|
||||
// read them, so we are in no position to decide they are disposable.
|
||||
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
|
||||
|
||||
/**
|
||||
* Stranded recovery (CORD-05/06).
|
||||
* Stranded detection (CORD-05/06).
|
||||
*
|
||||
* A Refounding carries only `(newRoot, newEpoch, rotator)` — there is **no
|
||||
* 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
|
||||
* everyone else moves on. This is true of any member, the owner included, and
|
||||
* cannot be prevented on the receive side.
|
||||
* set receives nothing and is silently stranded on the dead epoch, while everyone
|
||||
* else moves on. The invite link the membership was joined through
|
||||
* ([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
|
||||
* ([ConcordCommunityListEntry.inviteRef]): the community keeps publishing its
|
||||
* bundle at that same addressable coordinate, re-minted at the current epoch. So
|
||||
* a member who re-resolves their own join link and finds a **higher** epoch than
|
||||
* the one they hold knows they were left behind, and can merge forward.
|
||||
*
|
||||
* This object holds only the pure decision + merge; fetching and unlocking the
|
||||
* bundle at the link is the caller's job.
|
||||
* What a bundle may NOT do is move the base (CORD-06 §2, and the reference client's
|
||||
* `isCatchUpBundle`: "It may never move the base"). Nothing binds `community_root`
|
||||
* to `community_id`, so a bundle is not proof of continuity: a link creator — or
|
||||
* anyone who ever held a link's signer — could serve a higher-epoch bundle carrying
|
||||
* 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
|
||||
* whose `prevcommit` proves it extends the key we hold, from a rotator our roster
|
||||
* 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 {
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* it right now, have me on its banlist?" — and a `true` refuses the recovery
|
||||
* outright. It is a required argument rather than a caller-side `if` because
|
||||
* 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.
|
||||
* it right now, have me on its banlist?" — and a `true` answers false outright:
|
||||
* a removed member is not stranded, they are removed.
|
||||
*/
|
||||
fun isStranded(
|
||||
entry: ConcordCommunityListEntry,
|
||||
@@ -68,21 +67,18 @@ object ConcordStrandedRecovery {
|
||||
bundle.rootEpoch > entry.rootEpoch
|
||||
|
||||
/**
|
||||
* Merges [entry] forward onto the higher-epoch [bundle], or returns null when
|
||||
* there is nothing to do ([isStranded] is false) — so the caller can treat null
|
||||
* as "stay put" without a second check.
|
||||
* The entry that results from the user **explicitly** re-accepting the invite
|
||||
* link [bundle] was resolved from, while stranded on [entry] — or null when
|
||||
* [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
|
||||
* [isStranded]) and preserves two things the naive "adopt the bundle" would
|
||||
* destroy:
|
||||
*
|
||||
* - 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.
|
||||
* The merge is epoch-monotonic and preserves what a fresh join would lose: the
|
||||
* [ConcordCommunityListEntry.inviteRef] anchor, and the existing
|
||||
* [ConcordCommunityListEntry.heldRoots] plus the root we are leaving, so
|
||||
* prior-epoch history stays derivable.
|
||||
*/
|
||||
fun mergeForward(
|
||||
fun rejoinForward(
|
||||
entry: ConcordCommunityListEntry,
|
||||
bundle: CommunityInvite,
|
||||
bannedAtCurrentEpoch: Boolean,
|
||||
@@ -92,7 +88,7 @@ object ConcordStrandedRecovery {
|
||||
// Bank the epoch we are leaving with its control_pk, so its Control Plane
|
||||
// stays re-subscribable for the anti-rollback floor (a split epoch's address
|
||||
// is held, never derivable — CORD-02 §2).
|
||||
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }
|
||||
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch to it.key.lowercase() }
|
||||
|
||||
return ConcordCommunityListEntry(
|
||||
id = entry.id,
|
||||
@@ -109,10 +105,8 @@ object ConcordStrandedRecovery {
|
||||
name = entry.name.ifEmpty { bundle.name },
|
||||
addedAt = entry.addedAt,
|
||||
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,
|
||||
// or this recovery write silently deletes them.
|
||||
// or this write silently deletes them.
|
||||
residue = entry.residue,
|
||||
)
|
||||
}
|
||||
|
||||
+243
-33
@@ -21,7 +21,10 @@
|
||||
package com.vitorpamplona.quartz.concord.cord06Rekey
|
||||
|
||||
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.control.tags.VacTag
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
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.signers.NostrSigner
|
||||
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
|
||||
@@ -74,11 +78,51 @@ class ReceivedRefounding(
|
||||
val rotator: HexKey,
|
||||
val newControlPk: 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). */
|
||||
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
|
||||
* 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 staffXOnly the subset of [recipientsXOnly] that is staff (owner + Control-writing
|
||||
* 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(
|
||||
rotatorSigner: NostrSigner,
|
||||
@@ -125,11 +174,15 @@ object ConcordRefounding {
|
||||
staffXOnly: Set<HexKey>,
|
||||
createdAt: Long,
|
||||
ownerPubKey: HexKey,
|
||||
authority: AuthorityCitation? = null,
|
||||
mustCarry: Map<String, Long> = emptyMap(),
|
||||
): RefoundingBuild {
|
||||
val newEpoch = rootEpoch + 1
|
||||
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 prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
|
||||
@@ -146,6 +199,7 @@ object ConcordRefounding {
|
||||
prevEpoch = rootEpoch,
|
||||
prevCommit = prevCommit,
|
||||
createdAt = createdAt,
|
||||
authority = authority,
|
||||
)
|
||||
|
||||
return RefoundingBuild(newRoot, newControlRoot, newEpoch, newControlKeys, controlWraps, rekeyWraps)
|
||||
@@ -177,6 +231,7 @@ object ConcordRefounding {
|
||||
newControlKeys: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
ownerPubKey: HexKey,
|
||||
mustCarry: Map<String, Long> = emptyMap(),
|
||||
): List<Event> {
|
||||
// entity coordinate -> every edition we can open, paired with its verified seal.
|
||||
val byCoordinate = HashMap<String, MutableList<Pair<ControlEdition, Event>>>()
|
||||
@@ -205,19 +260,46 @@ object ConcordRefounding {
|
||||
val editions = byCoordinate.values.flatten()
|
||||
val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, communityId, ownerPubKey)
|
||||
val out = ArrayList<Event>(honored.size)
|
||||
for ((_, floor) in honored) {
|
||||
val head = floor.known ?: continue
|
||||
val seal = editions.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue
|
||||
val missing = ArrayList<String>()
|
||||
for ((entity, floor) in honored) {
|
||||
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))
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* [recipientsXOnly] — the [staffXOnly] subset also receiving [newControlRoot]
|
||||
* in the 136-byte staff form (CORD-06 §1) — chunked at
|
||||
* [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal,
|
||||
* in the 136-byte staff form (CORD-06 §1) — chunked by count and bytes
|
||||
* ([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
|
||||
* precomputes that address from the prior root — receives it live.
|
||||
*/
|
||||
@@ -233,6 +315,7 @@ object ConcordRefounding {
|
||||
prevEpoch: Long,
|
||||
prevCommit: HexKey,
|
||||
createdAt: Long,
|
||||
authority: AuthorityCitation? = null,
|
||||
): List<Event> {
|
||||
if (recipientsXOnly.isEmpty()) return emptyList()
|
||||
val staffLower = staffXOnly.mapTo(HashSet()) { it.lowercase() }
|
||||
@@ -248,10 +331,19 @@ object ConcordRefounding {
|
||||
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
|
||||
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))
|
||||
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
|
||||
* kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]),
|
||||
* verifies each is a well-formed root rotation to [newEpoch] whose `prevcommit`
|
||||
* continues the [priorRoot] the member holds, and returns the delivered new
|
||||
* root and Control Plane keys (with the rotator's real pubkey, so the caller
|
||||
* can authorize it against the folded roster). A staff blob's delivered secret
|
||||
* must derive to exactly the delivered `control_pk` (CORD-02 §5) — a
|
||||
* mismatched pair is refused rather than adopting a plane split from its
|
||||
* readers. Null if no chunk carries this member's blob — which only means
|
||||
* "removed" once the caller confirms it holds every chunk of the rotation.
|
||||
* verifies each is a well-formed root rotation to `rootEpoch + 1` whose
|
||||
* `prevepoch`/`prevcommit` continue the [priorRoot] the member holds, and returns
|
||||
* the delivered new root and Control Plane keys with the rotator's real pubkey and
|
||||
* `vac` citation, so the caller can authorize it against the folded roster.
|
||||
*
|
||||
* A rekey rumor must ride an **encrypted** (20013) seal (CORD-02 §5, Appendix B); a
|
||||
* malformed `chunk` tag (0-based, `i > n`, non-decimal) or `vac` tag drops the
|
||||
* 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(
|
||||
wraps: List<Event>,
|
||||
@@ -276,31 +379,138 @@ object ConcordRefounding {
|
||||
communityId: ByteArray,
|
||||
priorRoot: ByteArray,
|
||||
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 expectedScope = ConcordRekey.ROOT_SCOPE.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) {
|
||||
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
|
||||
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_PREVCOMMIT) != expectedCommit) continue
|
||||
|
||||
val blobs = ConcordRekey.decodeContent(rumor.content)
|
||||
val rotatorXOnly = opened.author.hexToByteArray()
|
||||
val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue
|
||||
val controlRoot = payload.newControlRoot
|
||||
val controlPk = payload.newControlPk
|
||||
if (controlRoot != null && controlPk != null) {
|
||||
// The staff derive-check (CORD-06 §1): refuse a pair whose secret does not
|
||||
// derive to the pk the other members were handed — fails closed.
|
||||
val derived = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, newEpoch).publicKey
|
||||
if (!derived.contentEquals(controlPk)) continue
|
||||
}
|
||||
return ReceivedRefounding(payload.newKey, newEpoch, opened.author, controlPk, controlRoot)
|
||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVEPOCH)?.toLongOrNull() != rootEpoch) continue
|
||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT)?.lowercase() != expectedCommit) continue
|
||||
if (ConcordRekey.chunkOf(rumor.tags) == null) continue
|
||||
// A present-but-malformed citation is a corrupt chunk; absent means the owner acts.
|
||||
val vacTag = rumor.tags.firstOrNull { it.isNotEmpty() && it[0] == VacTag.TAG_NAME }
|
||||
val citation = if (vacTag == null) null else VacTag.parse(vacTag) ?: continue
|
||||
byRotator.getOrPut(opened.author.lowercase()) { ArrayList() }.add(RekeyChunk(opened.author, rumor.content, citation))
|
||||
}
|
||||
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
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
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.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -85,7 +87,13 @@ object ConcordRekey {
|
||||
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(
|
||||
scopeId: ByteArray,
|
||||
newEpoch: Long,
|
||||
@@ -93,14 +101,78 @@ object ConcordRekey {
|
||||
prevCommit: HexKey,
|
||||
chunkIndex: Int,
|
||||
chunkTotal: Int,
|
||||
): Array<Array<String>> =
|
||||
arrayOf(
|
||||
arrayOf(TAG_SCOPE, scopeId.toHexKey()),
|
||||
arrayOf(TAG_NEWEPOCH, newEpoch.toString()),
|
||||
arrayOf(TAG_PREVEPOCH, prevEpoch.toString()),
|
||||
arrayOf(TAG_PREVCOMMIT, prevCommit),
|
||||
arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()),
|
||||
)
|
||||
authority: AuthorityCitation? = null,
|
||||
): Array<Array<String>> {
|
||||
require(chunkTotal >= 1 && chunkIndex in 1..chunkTotal) { "chunk must be 1..n, was $chunkIndex of $chunkTotal" }
|
||||
val base =
|
||||
arrayOf(
|
||||
arrayOf(TAG_SCOPE, scopeId.toHexKey()),
|
||||
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. */
|
||||
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. */
|
||||
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
|
||||
* 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.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
@@ -55,21 +58,14 @@ class ConcordInviteClassifyTest {
|
||||
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(
|
||||
linkSignerPubKey: String,
|
||||
linkSigner: ByteArray,
|
||||
vsk: String,
|
||||
createdAt: Long,
|
||||
content: String = "",
|
||||
) = Event(
|
||||
id = "00".repeat(32),
|
||||
pubKey = linkSignerPubKey,
|
||||
createdAt = createdAt,
|
||||
kind = ConcordInviteBundleEvent.KIND,
|
||||
tags = arrayOf(arrayOf("d", ""), VskTag.TAG_NAME.let { arrayOf(it, vsk) }),
|
||||
content = content,
|
||||
sig = "00".repeat(64),
|
||||
)
|
||||
dTag: String = "",
|
||||
): Event = NostrSignerSync(KeyPair(privKey = linkSigner)).sign(createdAt, ConcordInviteBundleEvent.KIND, arrayOf(arrayOf("d", dTag), arrayOf(VskTag.TAG_NAME, vsk)), content)
|
||||
|
||||
@Test
|
||||
fun liveBundleOpens() =
|
||||
@@ -77,7 +73,7 @@ class ConcordInviteClassifyTest {
|
||||
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 status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token)
|
||||
val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token)
|
||||
assertTrue(status is InviteBundleStatus.Live)
|
||||
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"))
|
||||
|
||||
// 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.
|
||||
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
|
||||
@@ -111,7 +107,7 @@ class ConcordInviteClassifyTest {
|
||||
|
||||
// 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 ->
|
||||
val status = ConcordInviteBundle.classify(wraps, minted.token)
|
||||
val status = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token)
|
||||
assertTrue(status is InviteBundleStatus.Live)
|
||||
assertEquals("bb".repeat(32), status.invite.communityRoot)
|
||||
assertEquals(2L, status.invite.rootEpoch)
|
||||
@@ -123,8 +119,9 @@ class ConcordInviteClassifyTest {
|
||||
runTest {
|
||||
// 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.
|
||||
val registry = coordinateEvent("aa".repeat(32), ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable")
|
||||
assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16)))
|
||||
val signer = KeyPair()
|
||||
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)
|
||||
|
||||
// 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)
|
||||
|
||||
// …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)
|
||||
assertEquals(community.communityIdHex, expired.invite.communityId)
|
||||
}
|
||||
@@ -167,12 +164,12 @@ class ConcordInviteClassifyTest {
|
||||
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, 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
|
||||
fun emptyFetchIsAbsent() {
|
||||
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16)))
|
||||
assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), "aa".repeat(32), ByteArray(16)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -201,12 +198,12 @@ class ConcordInviteClassifyTest {
|
||||
|
||||
// End to end: what the creator publishes is what every redeemer then resolves.
|
||||
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
|
||||
// refresh path must skip a coordinate it did not resolve Live first.
|
||||
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
|
||||
@@ -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"]]}"""
|
||||
val event = Event.fromJson(json)
|
||||
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.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConcordInviteLinkTest {
|
||||
@@ -67,9 +68,10 @@ class ConcordInviteLinkTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildUrlCarriesEveryRelayOfAnOversizedList() {
|
||||
// A community with five relays used to crash the mint ("at most 3 relays, was 5").
|
||||
// There is no cap below the format's own, so all five make the round trip.
|
||||
fun buildUrlTruncatesAnOversizedListToTheBootstrapCap() {
|
||||
// A community with five relays used to crash the mint ("at most 3 relays, was 5"), and was
|
||||
// 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 =
|
||||
listOf(
|
||||
"wss://one.example",
|
||||
@@ -80,7 +82,7 @@ class ConcordInviteLinkTest {
|
||||
)
|
||||
val parsed = ConcordInviteLink.parseUrl(ConcordInviteLink.buildUrl("https://vector.chat", signer, token, relays))
|
||||
assertNotNull(parsed)
|
||||
assertEquals(relays, parsed.fragment.relays)
|
||||
assertEquals(relays.take(3), parsed.fragment.relays)
|
||||
assertContentEquals(token, parsed.fragment.token)
|
||||
}
|
||||
|
||||
@@ -94,18 +96,6 @@ class ConcordInviteLinkTest {
|
||||
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
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
fun rejectsWrongVersion() {
|
||||
@@ -134,4 +124,32 @@ class ConcordInviteLinkTest {
|
||||
assertContentEquals(k, ConcordKeyDerivation.inviteBundleKey(token))
|
||||
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(!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(false, invite.icon?.isResolvable())
|
||||
|
||||
val status = ConcordInviteBundle.classify(listOf(event), token)
|
||||
val status = ConcordInviteBundle.classify(listOf(event), event.pubKey, token)
|
||||
assertIs<InviteBundleStatus.Live>(status)
|
||||
assertEquals("3rd times a charm?", status.invite.name)
|
||||
}
|
||||
|
||||
+26
-11
@@ -75,14 +75,14 @@ class ConcordStrandedRecoveryTest {
|
||||
name = "Gamers",
|
||||
)
|
||||
|
||||
// ---- merge forward --------------------------------------------------------
|
||||
// ---- explicit re-join (the user accepts the link again) ---------------------
|
||||
|
||||
@Test
|
||||
fun higherEpochBundleMergesForwardKeepingAnchorAndHistory() {
|
||||
fun anExplicitRejoinOfAHigherEpochBundleKeepsAnchorAndHistory() {
|
||||
val prior = HeldRoot(0L, "aa".repeat(32))
|
||||
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")
|
||||
|
||||
// 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 == 1L && it.key == "bb".repeat(32) })
|
||||
|
||||
// identity is untouched and we record where we were dropped
|
||||
// identity is untouched
|
||||
assertEquals(communityId, merged.id)
|
||||
assertEquals(stranded.addedAt, merged.addedAt)
|
||||
assertEquals(1L, merged.excludedAtEpoch)
|
||||
}
|
||||
|
||||
@Test
|
||||
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))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lowerEpochBundleIsANoOp() {
|
||||
// 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
|
||||
@@ -121,12 +120,12 @@ class ConcordStrandedRecoveryTest {
|
||||
// Direct invites and legacy entries have no anchor — expected, not an error.
|
||||
val noAnchor = entry(epoch = 1, ref = null)
|
||||
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
|
||||
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 ----------------------------
|
||||
@@ -260,8 +259,24 @@ class ConcordStrandedRecoveryTest {
|
||||
// very epoch a Refounding rotated them out of. See A2 in docs/concord-soft-ban-audit.md.
|
||||
val stranded = entry(epoch = 1)
|
||||
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".
|
||||
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 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 =
|
||||
RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
|
||||
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 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 =
|
||||
RumorAssembler.assembleRumor<Event>(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob)))
|
||||
val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now)
|
||||
|
||||
Reference in New Issue
Block a user