diff --git a/cli/README.md b/cli/README.md index 799d94bb3b..6854e836a3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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. | | `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). | | `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. | -| `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`). | | `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 6c2212a4e5..1cd6a91b71 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -33,6 +33,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.cord04Roles.AuthorityResolver @@ -43,6 +44,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 @@ -76,9 +78,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 @@ -278,7 +282,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 @@ -426,7 +432,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") @@ -462,7 +468,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, @@ -477,15 +483,53 @@ object ConcordCommands { // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. inviteRef = ConcordActions.bareInviteRef(url) ?: "", - ), - ) - 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 = csv?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() fun normalize(urls: List): Set = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() @@ -567,23 +611,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, @@ -591,6 +633,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 = @@ -604,54 +647,68 @@ object ConcordCommands { ctx.prepare() val results = mutableListOf>() 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.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, @@ -671,8 +728,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, @@ -688,21 +748,12 @@ object ConcordCommands { ctx.prepare() val results = mutableListOf>() 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 } @@ -711,12 +762,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.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.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)) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 4a4bf10a49..da7997bdc6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -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 @@ -35,6 +36,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 @@ -279,6 +283,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.owner) @@ -299,8 +308,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 @@ -327,45 +350,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.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 @@ -393,7 +438,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, @@ -415,6 +460,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, ), ) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index c36b1ac915..9668912d23 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -53,6 +53,17 @@ data class StoredCommunity( // rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct // invite or a community joined before amy stored it. val inviteRef: String = "", + // 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 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`. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 0a9699bd1c..8d1711795d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity 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 @@ -190,6 +191,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. */ @@ -426,6 +440,8 @@ object ConcordActions { name: String, relays: List, controlPk: HexKey? = null, + creator: HexKey? = null, + label: String? = null, ): CommunityInvite = CommunityInvite( communityId = communityIdHex, @@ -436,6 +452,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]). */ @@ -495,15 +515,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( @@ -516,13 +546,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, + 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 @@ -610,6 +642,8 @@ object ConcordActions { staffXOnly: Set, createdAt: Long, ownerPubKey: HexKey, + authority: AuthorityCitation? = null, + mustCarry: Map = emptyMap(), ): RefoundingBuild = ConcordRefounding.build( rotatorSigner = rotatorSigner, @@ -624,6 +658,8 @@ object ConcordActions { staffXOnly = staffXOnly, createdAt = createdAt, ownerPubKey = ownerPubKey, + authority = authority, + mustCarry = mustCarry, ) /** @@ -632,7 +668,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, @@ -641,5 +679,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) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt index b807ae24de..a915430e90 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt @@ -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, + authority: AuthorityResolver, + received: ReceivedRefounding, + ): Boolean { + if (!isAuthorizedRotator(authority, received.rotator)) return false + val heads = ConcordRotationAuthority.headsOf(editions, 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, + actor: HexKey, + ): AuthorityCitation? = ConcordRotationAuthority.citationFor(entry.id, actor, entry.owner, ConcordRotationAuthority.headsOf(editions, 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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 3215cbc06d..4596cc8327 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -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) }, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 7af62cb035..ba8bbd6892 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -52,6 +52,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 @@ -59,7 +63,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 @@ -77,6 +83,9 @@ import com.vitorpamplona.quartz.utils.concurrent.ConcurrentSet 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" @@ -98,6 +107,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, @@ -293,7 +305,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, @@ -351,7 +363,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 } @@ -496,7 +512,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 @@ -508,10 +524,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 @@ -550,6 +581,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, @@ -568,7 +612,7 @@ class AccountConcordActions( // recoverStrandedConcordCommunities(). inviteRef = ConcordActions.bareInviteRef(url), ) - joinConcordCommunity(entry) + joinConcordCommunity(entry, inviteCreator, inviteLabel) return ConcordInviteResult.Joined(bundle.communityId) } @@ -1081,6 +1125,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 @@ -1102,6 +1152,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 @@ -1121,7 +1184,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() } @@ -1131,52 +1194,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.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() + + /** + * 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, + ): List? { + val filter = ConcordActions.planeFilter(address) + val perRelay = + coroutineScope { + relays + .map { relay -> + async { + val events = ArrayList() + 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 } } /** @@ -1269,43 +1393,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) } } @@ -1341,6 +1501,14 @@ class AccountConcordActions( } } + private val _strandedConcordCommunities = MutableStateFlow>(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> = _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. @@ -1354,20 +1522,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. @@ -1381,6 +1547,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 = ( @@ -1391,36 +1572,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}" } - account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged)) - 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" } } } @@ -1541,7 +1700,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 + } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 8f40a07e67..626a7d397b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -31,6 +31,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 @@ -138,6 +139,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. @@ -163,6 +171,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. @@ -176,7 +187,9 @@ class ConcordCommunitySession( * survives a process restart without any new storage. */ private val historicalControlKeys: Map> = - 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) @@ -238,6 +251,7 @@ class ConcordCommunitySession( private val channelWrapsById = HashMap>() // channelIdHex -> (wrapId -> wrap) private val guestbookWraps = LinkedHashMap() private val baseRekeyWraps = LinkedHashMap() + private val siblingRekeyWraps = LinkedHashMap() // channel plane pubkey -> (channelIdHex, key), refreshed on each control re-fold. private var channelKeysByAddress = HashMap>() @@ -328,6 +342,7 @@ class ConcordCommunitySession( address == controlPlaneAddress || address == guestbookAddress || address == nextBaseRekeyAddress || + address == siblingBaseRekeyAddress || address == dissolvedAddress || address in historicalControlKeys || lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress } @@ -355,6 +370,12 @@ class ConcordCommunitySession( /** The buffered kind-3303 base-rotation wraps seen at [nextBaseRekeyAddress], for the account to drain. */ fun pendingBaseRekeyWraps(): List = 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 = 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 @@ -389,7 +410,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 = listOf(guestbookKey, nextBaseRekeyKey, dissolvedKey) + fun auxStreamKeys(): List = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey) /** The community's current Control Plane editions — the input a moderation edition chains onto. */ fun controlEditions(): List = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) } @@ -489,6 +510,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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordRotationReceiveTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordRotationReceiveTest.kt new file mode 100644 index 0000000000..ec6ba6a5da --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordRotationReceiveTest.kt @@ -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, + ) + + /** 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, 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 = 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 { + 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, + ): ReceivedRefounding? { + val entry = entryFor(f.community) + val authority = ConcordCommunityState.fold(f.editions, 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) }) + } +} diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index 712d3bf2f3..9bfe0606c8 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -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 | open → chat-plane batch | | 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 | open → chat-plane 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 | 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 | open → control-plane batch | | 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 | open → control-plane batch | | 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 | open → control-plane batch | | S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | open → control-plane batch | | 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` | open → control-plane + chat-plane batches | | S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | open → chat-plane batch | -| 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 | open → control-plane batch | | I6 | 04 §1 | First edition is v0; spec says versions start at 1 | open → control-plane batch | | I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | open → control-plane batch | -| 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) | open → chat-plane batch | | I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | open → chat-plane batch | | I15 | 02 §4 | No `ms` tag on chat rumors | open → chat-plane batch | | I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | open → chat-plane batch | | I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | open → control-plane 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 | 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt index f4b1277a9e..ac0ce7bd16 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt @@ -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(rumor.content) + // Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply"). + return ConcordJson.decodeOrNull(rumor.content)?.let { ConcordInviteBundle.bound(it) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt index 50ec38bd10..0f57cf1d7e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -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(Nip44.v2.decrypt(event.content, bundleKey)) + ConcordJson.decodeOrNull(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, + 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, + 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt index cfeb071e83..d6f49bc5e4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt index f136aed3fd..cfa66df555 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt @@ -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 = emptyList(), val tombstones: List = emptyList(), val residue: JsonObject = NoExtras, val opaqueEntries: List = emptyList(), + val opaqueTombstones: List = emptyList(), ) { companion object { val EMPTY = ConcordInviteListDocument() @@ -201,14 +206,16 @@ object ConcordInviteList { } } + val opaqueTombstones = mutableListOf() 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() - 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() 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, ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt index 9869cadb91..e4dec672c0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt @@ -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, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt index 74fb73b594..aef5fec8f2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt @@ -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, +) : 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, createdAt: Long, ownerPubKey: HexKey, + authority: AuthorityCitation? = null, + mustCarry: Map = emptyMap(), ): RefoundingBuild { val newEpoch = rootEpoch + 1 val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot) - val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, 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, 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) @@ -169,6 +223,7 @@ object ConcordRefounding { priorControlKeys: ControlPlaneKeys, newControlKeys: ControlPlaneKeys, ownerPubKey: HexKey, + mustCarry: Map = emptyMap(), ): List { // entity coordinate -> every edition we can open, paired with its verified seal. val byCoordinate = HashMap>>() @@ -197,19 +252,45 @@ object ConcordRefounding { val editions = byCoordinate.values.flatten() val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, ownerPubKey) val out = ArrayList(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() + 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, + ownerPubKey: HexKey, + ): Map = ConcordCommunityState.authorizedHeads(editions, 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. */ @@ -225,6 +306,7 @@ object ConcordRefounding { prevEpoch: Long, prevCommit: HexKey, createdAt: Long, + authority: AuthorityCitation? = null, ): List { if (recipientsXOnly.isEmpty()) return emptyList() val staffLower = staffXOnly.mapTo(HashSet()) { it.lowercase() } @@ -240,10 +322,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(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(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk)) ConcordStreamEnvelope.wrap(rumor, baseRekeyKey, rotatorSigner, encrypted = true, createdAt = createdAt) } @@ -252,14 +343,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, @@ -268,31 +370,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, + baseRekeyKey: GroupKey, + recipientSigner: NostrSigner, + communityId: ByteArray, + priorRoot: ByteArray, + rootEpoch: Long, + ): List { 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>() 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() + 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? = 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): List = + 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), + ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt index 84d9c1910d..7e4774ee5d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt @@ -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> = - 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> { + 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>): Pair? { + 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, + envelopeBytes: Int, + ): List> { + val budget = REKEY_RUMOR_MAX_BYTES - envelopeBytes + val chunks = ArrayList>() + var current = ArrayList() + 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): 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRotationAuthority.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRotationAuthority.kt new file mode 100644 index 0000000000..4874639ab1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRotationAuthority.kt @@ -0,0 +1,94 @@ +/* + * 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, + ownerPubKey: HexKey, + ): Map = ConcordCommunityState.authorizedHeads(editions, 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, + ): 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, + ): 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 + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt index 064ec219e7..dfe2fa8070 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt @@ -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(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(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(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(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(status) + assertEquals(relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), status.invite.relays) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt index ef63c80014..236156c180 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt @@ -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 { 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 { ConcordInviteLink.decodeFragment(fragment) } + assertNull(ConcordInviteLink.parseUrl("https://x/invite/" + ConcordInviteLink.buildUrl("https://x", "aa".repeat(32), ByteArray(16)).substringAfter("/invite/").substringBefore('#') + "#" + fragment)) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt index b3a6dbde5e..3f0447b2a6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt @@ -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") + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRelayopInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRelayopInteropTest.kt index 63bed6cb5a..11fd70a156 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRelayopInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRelayopInteropTest.kt @@ -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(status) assertEquals("3rd times a charm?", status.invite.name) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt index 96c1124e57..4a8bdda07f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt @@ -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 `#` 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) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyConformanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyConformanceTest.kt new file mode 100644 index 0000000000..ae433b9faa --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyConformanceTest.kt @@ -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, + staff: Set = emptySet(), + authority: AuthorityCitation? = null, + ): List { + 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, + 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 { 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(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, 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 { + ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, owner.pubKey, mustCarry = heads + ("ff".repeat(32) to 0L)) + } + assertEquals(listOf("ff".repeat(32)), missing.missing) + val first = heads.keys.first() + assertFailsWith { + ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, owner.pubKey, mustCarry = mapOf(first to heads.getValue(first) + 1)) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt index 7a4440e1a0..7c8d0bfe15 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt @@ -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(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(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob))) val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now)