fix(marmot): leaveGroup sends a SelfRemove-only commit, not a standalone proposal
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
This commit is contained in:
+11
-8
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-6
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user