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 a4fc0e665..d8a509ab8 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,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) 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 53f9e45e7..f772f680f 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 @@ -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 { 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 } } 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 2ab461ccc..ddab3c19d 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,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 = 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 }