fix(marmot): send SelfRemove as standalone PublicMessage proposal

Reverts the SelfRemove-as-commit approach. openmls rejects a
commit whose only proposal is a SelfRemove from the committer with
RequiredPathNotFound — the spec model is that the removing member
publishes a STANDALONE proposal, and an admin receiver
(wn's mdk auto-commit path) folds it into their next commit.

Add `MlsGroup.buildSelfRemoveProposalMessage()` which frames
Proposal.SelfRemove as `MlsMessage(PublicMessage{proposal})` with
the proper FramedContent / signature / membership_tag and returns
the ready-to-publish bytes + the pre-commit exporter key for outer
encryption. Rewire `MlsGroupManager.leaveGroup` and
`MarmotManager.leaveGroup` to use it.

Target: test 11 (amy leave → B auto-commits SelfRemove →
removes amy from member list).

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
This commit is contained in:
Claude
2026-04-22 08:21:23 +00:00
parent d68c6a2fe7
commit 72d1793a25
3 changed files with 77 additions and 29 deletions
@@ -290,16 +290,16 @@ class MarmotManager(
* Returns proposal bytes to publish (as a GroupEvent).
*/
suspend fun leaveGroup(nostrGroupId: HexKey): OutboundGroupEvent {
// 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)
// leaveGroup() returns the framed standalone SelfRemove proposal
// (PublicMessage{Proposal}) plus the pre-commit exporter key for
// outer encryption. Runs BEFORE we tear down the subscriptions so
// the pre-commit exporter is still derivable.
val (framedBytes, exporterKey) = groupManager.leaveGroup(nostrGroupId)
val outboundEvent =
outboundProcessor.buildCommitEvent(
nostrGroupId = nostrGroupId,
commitBytes = commitResult.framedCommitBytes,
exporterKey = commitResult.preCommitExporterSecret,
commitBytes = framedBytes,
exporterKey = exporterKey,
)
subscriptionManager.unsubscribeGroup(nostrGroupId)
@@ -2681,22 +2681,75 @@ class MlsGroup private constructor(
* calls from a member currently listed in `admin_pubkeys`.
*/
/**
* 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.
* Build a standalone SelfRemove proposal framed as a PublicMessage MLS
* message (RFC 9420 §6.2 + draft-ietf-mls-extensions).
*
* Caller is responsible for publishing the `framedCommitBytes` as the
* kind:445 outer layer, outer-encrypted with
* [CommitResult.preCommitExporterSecret].
* openmls/mdk explicitly treat SelfRemove as a STANDALONE proposal
* message, not a commit body: a non-admin committer can't self-remove
* (openmls returns `RequiredPathNotFound`/`AttemptedSelfRemoval`) so
* quartz must publish it as a plain PROPOSAL instead. Admin receivers
* (wn's mdk auto-commit path) pick up the staged proposal and fold it
* into their next commit, which is what actually removes the sender.
*
* The bytes returned are the full `MlsMessage(PublicMessage(proposal))`
* ready for outer ChaCha20 wrapping as kind:445 content. The second
* return value is the epoch this message must be outer-encrypted under.
*/
fun selfRemoveCommit(): CommitResult {
fun buildSelfRemoveProposalMessage(): Pair<ByteArray, ByteArray> {
check(!isLocalAdmin()) {
"Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)"
}
proposeSelfRemove()
return commit()
val preCommitExporterSecret =
exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
val proposal = Proposal.SelfRemove()
val proposalBytes = proposal.toTlsBytes()
val ctx = groupContext
val ctxBytes = ctx.toTlsBytes()
val preCommitMembershipKey = epochSecrets.membershipKey
val preCommitSigningKey = signingPrivateKey
// FramedContentTBS for a member-sender PROPOSAL over PublicMessage:
// version || wire_format || FramedContent || serialized_context
val tbsWriter = TlsWriter()
tbsWriter.putUint16(MlsMessage.MLS_VERSION_10)
tbsWriter.putUint16(WireFormat.PUBLIC_MESSAGE.value)
tbsWriter.putOpaqueVarInt(ctx.groupId)
tbsWriter.putUint64(ctx.epoch)
encodeSender(tbsWriter, Sender(SenderType.MEMBER, myLeafIndex))
tbsWriter.putOpaqueVarInt(ByteArray(0)) // authenticated_data
tbsWriter.putUint8(ContentType.PROPOSAL.value)
tbsWriter.putBytes(proposalBytes) // proposal struct, no outer length prefix
tbsWriter.putBytes(ctxBytes) // member sender appends context
val tbs = tbsWriter.toByteArray()
val signature = MlsCryptoProvider.signWithLabel(preCommitSigningKey, "FramedContentTBS", tbs)
// TBM = TBS || FramedContentAuthData. For PROPOSAL there's no
// confirmation_tag, just the signature.
val tbmWriter = TlsWriter()
tbmWriter.putBytes(tbs)
tbmWriter.putOpaqueVarInt(signature)
val tbm = tbmWriter.toByteArray()
val macInstance = MacInstance("HmacSHA256", preCommitMembershipKey)
macInstance.update(tbm)
val membershipTag = macInstance.doFinal()
val publicMessage =
PublicMessage(
groupId = ctx.groupId,
epoch = ctx.epoch,
sender = Sender(SenderType.MEMBER, myLeafIndex),
authenticatedData = ByteArray(0),
contentType = ContentType.PROPOSAL,
content = proposalBytes,
signature = signature,
confirmationTag = null,
membershipTag = membershipTag,
)
return MlsMessage.fromPublicMessage(publicMessage).toTlsBytes() to preCommitExporterSecret
}
}
@@ -465,20 +465,15 @@ class MlsGroupManager(
}
/**
* 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.
* Leave a group by publishing a standalone PublicMessage SelfRemove
* proposal (draft-ietf-mls-extensions). Admin receivers auto-commit
* the pending proposal; the caller publishes the framed bytes as the
* kind:445 content, outer-encrypted with the returned exporter key.
*/
suspend fun leaveGroup(nostrGroupId: HexKey): CommitResult =
suspend fun leaveGroup(nostrGroupId: HexKey): Pair<ByteArray, ByteArray> =
mutex.withLock {
val group = requireGroup(nostrGroupId)
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)
val result = group.buildSelfRemoveProposalMessage()
removeGroupStateUnlocked(nostrGroupId)
result
}