From b01ee5336ad375ebf81a087e5d8b6c1b42266233 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 07:46:05 +0000 Subject: [PATCH] fix(marmot): leaveGroup sends a SelfRemove-only commit, not a standalone proposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quartz's leaveGroup was returning `proposal.toTlsBytes()` and publishing that raw on kind:445. That's not a valid MLS message — it's just the proposal struct with no FramedContent / MlsMessage framing — and even if it parsed, mdk/openmls would only STAGE the proposal. The target never actually leaves the tree until someone commits with that proposal. MIP-03 explicitly allows non-admins to commit a SelfRemove-only commit, so amy can produce a proper committable kind:445 herself after the self-demote. Replace `selfRemove()` (returned raw bytes) with `selfRemoveCommit()` which adds the proposal to the pending set and runs the standard `commit()` path. Plumb the resulting CommitResult through MarmotManager.leaveGroup so the outer encryption uses `preCommitExporterSecret` (the epoch we're leaving, which is the one every existing member still has). This finally propagates amy's leave to B in test 11. https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484 --- .../amethyst/commons/marmot/MarmotManager.kt | 19 +++++++++++-------- .../quartz/marmot/mls/group/MlsGroup.kt | 17 ++++++++++++++--- .../marmot/mls/group/MlsGroupManager.kt | 18 ++++++++++++------ 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 6f76762f6..a4fc0e665 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -290,15 +290,18 @@ class MarmotManager( * Returns proposal bytes to publish (as a GroupEvent). */ suspend fun leaveGroup(nostrGroupId: HexKey): OutboundGroupEvent { - // Build the outbound event BEFORE deleting group state (needs exporter secret) - val group = - groupManager.getGroup(nostrGroupId) - ?: throw IllegalStateException("Not a member of group $nostrGroupId") - val proposalBytes = group.selfRemove() - val outboundEvent = outboundProcessor.buildCommitEvent(nostrGroupId, proposalBytes) + // leaveGroup() builds a SelfRemove-only commit (MIP-03 non-admin + // exception) and removes local state. It must run BEFORE we tear + // down the subscriptions so the pre-commit exporter key is still + // reachable for outer encryption. + val commitResult = groupManager.leaveGroup(nostrGroupId) + val outboundEvent = + outboundProcessor.buildCommitEvent( + nostrGroupId = nostrGroupId, + commitBytes = commitResult.framedCommitBytes, + exporterKey = commitResult.preCommitExporterSecret, + ) - // Now clean up group state - groupManager.removeGroupState(nostrGroupId) subscriptionManager.unsubscribeGroup(nostrGroupId) try { messageStore?.delete(nostrGroupId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index 7a0386721..53f9e45e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -2680,12 +2680,23 @@ class MlsGroup private constructor( * Per MIP-01/MIP-03, admins must self-demote first; this helper rejects * calls from a member currently listed in `admin_pubkeys`. */ - fun selfRemove(): ByteArray { + /** + * Build a SelfRemove commit (not a standalone proposal). MIP-03 allows + * non-admins to commit a SelfRemove-only commit, and only a commit + * actually rewrites the tree on the receiver side — a standalone + * SelfRemove proposal just gets staged by mdk/openmls and never + * removes the member unless another admin commits. + * + * Caller is responsible for publishing the `framedCommitBytes` as the + * kind:445 outer layer, outer-encrypted with + * [CommitResult.preCommitExporterSecret]. + */ + fun selfRemoveCommit(): CommitResult { check(!isLocalAdmin()) { "Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)" } - val proposal = Proposal.SelfRemove() - return proposal.toTlsBytes() + proposeSelfRemove() + return commit() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt index 8cc77f19f..2ab461ccc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt @@ -465,16 +465,22 @@ class MlsGroupManager( } /** - * Leave a group (self-remove). - * Returns the SelfRemove proposal bytes to publish, then removes - * local state. + * Leave a group via a SelfRemove-only commit (MIP-03 non-admin exception). + * Returns the framed commit bytes + pre-commit exporter secret so the + * caller can outer-encrypt the kind:445 under the epoch the commit is + * leaving, then removes local state. */ - suspend fun leaveGroup(nostrGroupId: HexKey): ByteArray = + suspend fun leaveGroup(nostrGroupId: HexKey): CommitResult = mutex.withLock { val group = requireGroup(nostrGroupId) - val proposalBytes = group.selfRemove() + val retainedBefore = group.retainedSecrets() + val result = group.selfRemoveCommit() + // Record the retained epoch so any relay echo of the commit or + // earlier-epoch traffic can still be decrypted by the caller + // while it handles cleanup. + pushRetainedEpoch(nostrGroupId, retainedBefore) removeGroupStateUnlocked(nostrGroupId) - proposalBytes + result } /**