feat(concord): re-mint invite links on Refounding so stranded recovery fires

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 <stranger>  → 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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-09 19:06:39 -04:00
co-authored by Claude Opus 5
parent 0aa3adfc07
commit 409339b375
4 changed files with 93 additions and 1 deletions
@@ -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,
@@ -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
@@ -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<StoredMintedInvite> = 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`. */
@@ -436,6 +436,29 @@ object ConcordActions {
relays: List<String>? = 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)