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:
+7
-7
@@ -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)
|
||||
|
||||
+64
-11
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-11
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user