mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
co-authored by
Claude Opus 5
parent
0aa3adfc07
commit
409339b375
@@ -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`. */
|
||||
|
||||
+23
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user