From 409339b375d34666be96a8d215f413efe52decd3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 9 Aug 2026 19:06:39 -0400 Subject: [PATCH] feat(concord): re-mint invite links on Refounding so stranded recovery fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the liveness half of A2. Recovery's whole premise is that the community keeps re-minting its bundle at the SAME addressable coordinate, so the link a stranded member already holds starts pointing at the new epoch. Nothing did: `ConcordInviteBundle.mintLink` generates a fresh KeyPair and token per call, and no client persisted `linkSignerPrivKey`. Every mint was a new coordinate, so `recover` could only ever return `already_current` — the mechanism was dead code, and an owner evicted by a rogue admin had no way back. The kind-33301 bundle is addressable and authored by the link signer, so re-signing at that coordinate with the same token replaces what is there and every holder of that link keeps working. Exposes that as `ConcordActions.remintBundleAt`, persists the link signer + token in amy's store at mint time, and has `concord refound` refresh every link it minted for the new epoch. Re-minting every live link is safe precisely because the security half is already in: `refound` bans the removed members on the way out, and `recover` reads the banlist of the epoch being LEFT, so a removed member's own recovery is refused even though their link now resolves. That gate stops being belt-and-braces here and becomes load-bearing — which is what the audit predicted for any client that re-mints (Armada does). Verified end to end against a loopback geode, both directions: bob joins by link, holds no role, never posts (unfindable by a rotation) alice refound --remove → recipients=1, invites_refreshed=1 bob rekey → no_blob_for_us (genuinely stranded) bob recover → recovered, epoch 0 → 1 ← first time this has ever fired bob reads the community at the new epoch alice refound --remove bob → epoch 1 → 2, invites_refreshed=1 bob recover → refused, reason "banned", still at epoch 1 Still open for the shipping client: Amethyst persists no link signer, so A2 liveness remains open on Android. Doing it there means deciding where the secret lives in the kind-13302 list, which Armada also reads — a wire-schema call, not a code one. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/cli/commands/ConcordCommands.kt | 15 +++++++ .../cli/commands/ConcordModCommands.kt | 40 ++++++++++++++++++- .../amethyst/cli/stores/ConcordStore.kt | 16 ++++++++ .../commons/actions/ConcordActions.kt | 23 +++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) 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 1e299b4191..3e06aa5b72 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 @@ -27,6 +27,7 @@ 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.StoredHeldRoot +import com.vitorpamplona.amethyst.cli.stores.StoredMintedInvite import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -265,6 +266,20 @@ object ConcordCommands { val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } + // Keep the link signer + token so a later Refounding can refresh THIS coordinate rather + // than orphaning the link at a dead epoch — the liveness half of stranded recovery (A2). + ConcordStore(dataDir.concordFile).upsert( + sc.copy( + mintedInvites = + sc.mintedInvites + + StoredMintedInvite( + linkSignerPrivKey = minted.linkSignerPrivKey.toHexKey(), + token = minted.token.toHexKey(), + createdAt = TimeUtils.now(), + ), + ), + ) + Output.emit( mapOf( "url" to minted.url, 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 a9e8b0d2d8..342ae2501d 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 @@ -322,7 +322,44 @@ object ConcordModCommands { build.newControlKeys.address.hexToByteArray(), newControlRoot, ) - ConcordStore(dataDir.concordFile).upsert(ConcordCommands.storedFrom(loaded.community, adopted)) + val stored = ConcordCommands.storedFrom(loaded.community, adopted) + ConcordStore(dataDir.concordFile).upsert(stored) + + // 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new + // epoch. This is the liveness half of stranded recovery (A2): a member this Refounding + // left out has no rekey blob and no message to miss, so re-resolving their link is the + // only way back — and it only works if the bundle moves with the community instead of + // being orphaned at a dead epoch. Minting a fresh link would not help them; the link + // they hold is the one that must move. + // + // Safe for every link because recovery is ban-gated at the epoch being left, and step 1 + // banned everyone being removed — so a removed member's own `recover` is refused even + // though their link now resolves. + val refreshedInvite = + ConcordActions.inviteFor( + stored.communityId, + stored.owner, + stored.ownerSalt, + stored.root, + stored.rootEpoch, + stored.name, + stored.relays, + stored.controlPk.ifBlank { null }, + ) + var refreshed = 0 + for (link in stored.mintedInvites) { + runCatching { + val event = + ConcordActions.remintBundleAt( + linkSignerPrivKey = link.linkSignerPrivKey.hexToByteArray(), + token = link.token.hexToByteArray(), + invite = refreshedInvite, + createdAt = TimeUtils.now(), + ) + ctx.publish(event, relays) + refreshed++ + } + } Output.emit( mapOf( @@ -333,6 +370,7 @@ object ConcordModCommands { "recipients" to recipients.size, "control_wraps" to build.controlWraps.size, "rekey_wraps" to build.rekeyWraps.size, + "invites_refreshed" to refreshed, ), ) return 0 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 d2de85a258..e08bfcc8f8 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,22 @@ 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 = "", + // Invite links WE minted for this community, kept so a Refounding can re-publish each bundle at + // its own coordinate for the new epoch. Without this the link a member joined through points at + // a dead epoch forever and stranded recovery can never fire (A2). Holds link-signer secrets, so + // it sits beside `root`/`controlRoot` in the same already-secret file. + val mintedInvites: List = emptyList(), +) + +/** + * One invite link this account minted: enough to re-sign at its addressable coordinate later. The + * coordinate is the link signer's pubkey, so keeping the private key is what lets a Refounding + * refresh the link (and, in future, revoke it) instead of orphaning it. + */ +data class StoredMintedInvite( + val linkSignerPrivKey: String = "", + val token: String = "", + val createdAt: Long = 0, ) /** 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 e70fe18364..6263c65add 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 @@ -436,6 +436,29 @@ object ConcordActions { relays: List? = null, ): MintedInviteLink = ConcordInviteBundle.mintLink(base, invite, createdAt, relays) + /** + * Re-publishes a bundle at an **existing** link's coordinate, carrying [invite] refreshed for the + * current epoch (CORD-05 §1). The kind-33301 bundle is addressable and authored by the link + * signer, so re-signing with the same [linkSignerPrivKey] and re-encrypting under the same + * [token] replaces what is there — every holder of that link keeps working, now pointing at the + * new root. + * + * This is what makes stranded recovery live: a member a Refounding left out has no rekey blob and + * no message to miss, and re-resolving their link is the only way back — which requires the + * community to re-mint at the *same* coordinate rather than issuing a fresh link. Minting a new + * link leaves the old one pointing at a dead epoch forever. + * + * Safe to call for every live link because recovery is ban-gated at the epoch being left + * (CORD-06, A2): a member the Refounding removed was banned on the way out, so their own + * `recover` is refused even though their link now resolves. + */ + fun remintBundleAt( + linkSignerPrivKey: ByteArray, + token: ByteArray, + invite: CommunityInvite, + createdAt: Long, + ): Event = ConcordInviteBundle.build(linkSignerPrivKey, token, invite, createdAt) + /** Parses a shareable invite URL into its pointer + private fragment. */ fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url)