From 236ebfe4d6f91e46dda43f4bc240bda094626492 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 12:49:01 +0000 Subject: [PATCH] fix(marmot): apply RFC 9420 PrivateMessageContent framing to app messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MlsGroup.encrypt / decrypt used to pass raw application bytes as the AEAD plaintext — no PrivateMessageContent framing, no FramedContentTBS signature, no padding. Amethyst ↔ Amethyst round-trips worked (both sides skipped it the same way) but every cross-implementation message was unreadable. Per RFC 9420 §6.3.1 the AEAD plaintext for an application PrivateMessage is the serialized PrivateMessageContent: opaque application_data; // varint-prefixed payload opaque signature; // FramedContentAuthData.signature opaque padding[N]; // zero padding (N may be 0) where the signature is `SignWithLabel(., "FramedContentTBS", FramedContentTBS)` computed over (version || wire_format || FramedContent || GroupContext). This change wires both sides up end-to-end: * encrypt now builds FramedContentTBS via the new buildApplicationFramedContentTbs helper, signs it with the caller's signature private key, and writes application_data || signature (zero-length padding) as the AEAD plaintext. * decrypt parses application_data and signature from the AEAD plaintext, asserts all remaining bytes are zero padding, rebuilds FramedContentTBS for the sender's leaf, and verifies the Ed25519 signature against that leaf's signatureKey before returning the application payload. Non-application PrivateMessages now error (commit/proposal-inside-PrivateMessage was never correctly handled by the old raw-bytes path either; leaving that for a follow-up). Un-Ignore MdkWelcomeInteropTest.testBobDecryptsMdkApplicationMessagesFromAlice — Amethyst now decrypts and verifies each of the three openmls-authored application messages against Alice's committer leaf. https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr --- .../quartz/marmot/mls/group/MlsGroup.kt | 114 +++++++++++++++++- .../marmot/mls/MdkWelcomeInteropTest.kt | 28 ++--- 2 files changed, 118 insertions(+), 24 deletions(-) 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 9fda395b7a..352c147330 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 @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage import com.vitorpamplona.quartz.marmot.mls.framing.Sender import com.vitorpamplona.quartz.marmot.mls.framing.SenderType import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat +import com.vitorpamplona.quartz.marmot.mls.framing.encodeSender import com.vitorpamplona.quartz.marmot.mls.messages.Commit import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult import com.vitorpamplona.quartz.marmot.mls.messages.EncryptedGroupSecrets @@ -577,7 +578,17 @@ class MlsGroup private constructor( // --- Message Encryption --- /** - * Encrypt an application message as a PrivateMessage (RFC 9420 Section 6.3). + * Encrypt an application message as a PrivateMessage (RFC 9420 §6.3). + * + * The AEAD plaintext is the serialized [PrivateMessageContent] for + * application messages (§6.3.1): + * + * opaque application_data; + * FramedContentAuthData auth; // = opaque signature + * opaque padding[N]; // zero padding + * + * The signature is computed with `SignWithLabel(., "FramedContentTBS", + * FramedContentTBS)` using the member's signature private key. */ fun encrypt(plaintext: ByteArray): ByteArray { // Trim sentKeys if it grows too large @@ -599,9 +610,33 @@ class MlsGroup private constructor( guardedNonce[i] = (guardedNonce[i].toInt() xor reuseGuard[i].toInt()).toByte() } + // Sign FramedContentTBS for this application message (RFC 9420 §6.1). + val signature = + MlsCryptoProvider.signWithLabel( + signingPrivateKey, + "FramedContentTBS", + buildApplicationFramedContentTbs( + groupId = groupId, + epoch = epoch, + senderLeafIndex = myLeafIndex, + authenticatedData = ByteArray(0), + applicationData = plaintext, + groupContext = groupContext, + ), + ) + + // Build PrivateMessageContent plaintext (RFC 9420 §6.3.1). + // Padding length is zero; RFC allows any non-negative padding + // provided the padding bytes themselves are zero. + val pmcWriter = TlsWriter() + pmcWriter.putOpaqueVarInt(plaintext) // application_data + pmcWriter.putOpaqueVarInt(signature) // FramedContentAuthData.signature + // (application messages have no confirmation_tag field) + val pmcPlaintext = pmcWriter.toByteArray() + // Build PrivateContentAAD (RFC 9420 §6.3.2) val contentAad = buildPrivateContentAAD(groupId, epoch, ContentType.APPLICATION, ByteArray(0)) - val ciphertext = MlsCryptoProvider.aeadEncrypt(kng.key, guardedNonce, contentAad, plaintext) + val ciphertext = MlsCryptoProvider.aeadEncrypt(kng.key, guardedNonce, contentAad, pmcPlaintext) // Build sender data plaintext: leaf_index || generation || reuse_guard val senderDataWriter = TlsWriter() @@ -731,16 +766,87 @@ class MlsGroup private constructor( // Decrypt content with PrivateContentAAD (RFC 9420 §6.3.2) val contentAad = buildPrivateContentAAD(privMsg.groupId, privMsg.epoch, privMsg.contentType, privMsg.authenticatedData) - val plaintext = MlsCryptoProvider.aeadDecrypt(kng.key, guardedNonce, contentAad, privMsg.ciphertext) + val pmcPlaintext = MlsCryptoProvider.aeadDecrypt(kng.key, guardedNonce, contentAad, privMsg.ciphertext) + + // Parse PrivateMessageContent (RFC 9420 §6.3.1) and verify the + // sender's FramedContentTBS signature before returning bytes. + require(privMsg.contentType == ContentType.APPLICATION) { + // Commit/Proposal-via-PrivateMessage decoding is not implemented. + "decrypt() only supports application-content PrivateMessages" + } + val pmcReader = TlsReader(pmcPlaintext) + val applicationData = pmcReader.readOpaqueVarInt() + val signature = pmcReader.readOpaqueVarInt() + // Remaining bytes are padding; all must be zero per §6.3.1. + while (pmcReader.hasRemaining) { + require(pmcReader.readBytes(1)[0] == 0.toByte()) { + "PrivateMessageContent padding must be zero" + } + } + + val senderLeaf = + requireNotNull(tree.getLeaf(senderLeafIndex)) { + "Sender leaf is blank at index $senderLeafIndex" + } + require( + MlsCryptoProvider.verifyWithLabel( + senderLeaf.signatureKey, + "FramedContentTBS", + buildApplicationFramedContentTbs( + groupId = privMsg.groupId, + epoch = privMsg.epoch, + senderLeafIndex = senderLeafIndex, + authenticatedData = privMsg.authenticatedData, + applicationData = applicationData, + groupContext = groupContext, + ), + signature, + ), + ) { "FramedContentTBS signature verification failed" } return DecryptedMessage( senderLeafIndex = senderLeafIndex, contentType = privMsg.contentType, - content = plaintext, + content = applicationData, epoch = privMsg.epoch, ) } + /** + * Build FramedContentTBS for an application-content PrivateMessage + * (RFC 9420 §6.1). The signature over this is what lives in the + * PrivateMessageContent's FramedContentAuthData. + * + * struct { + * ProtocolVersion version = mls10; + * WireFormat wire_format; // = mls_private_message + * FramedContent content; // member sender, app body + * GroupContext context; // for member senders + * } FramedContentTBS; + */ + private fun buildApplicationFramedContentTbs( + groupId: ByteArray, + epoch: Long, + senderLeafIndex: Int, + authenticatedData: ByteArray, + applicationData: ByteArray, + groupContext: GroupContext, + ): ByteArray { + val w = TlsWriter() + w.putUint16(MlsMessage.MLS_VERSION_10) + w.putUint16(WireFormat.PRIVATE_MESSAGE.value) + // FramedContent + w.putOpaqueVarInt(groupId) + w.putUint64(epoch) + encodeSender(w, Sender(SenderType.MEMBER, senderLeafIndex)) + w.putOpaqueVarInt(authenticatedData) + w.putUint8(ContentType.APPLICATION.value) + w.putOpaqueVarInt(applicationData) // application_data + // GroupContext (member sender) + groupContext.encodeTls(w) + return w.toByteArray() + } + // --- Commit Processing --- /** diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt index b3ef8503c4..38eb366374 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt @@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -159,30 +158,19 @@ class MdkWelcomeInteropTest { } /** - * Application-message interop currently fails because Amethyst's - * [MlsGroup.encrypt] / [MlsGroup.decrypt] treat the AEAD plaintext as - * raw application bytes — they skip the [RFC 9420 §6.3.1 - * PrivateMessageContent] framing: + * RFC 9420 §6.3.1 PrivateMessageContent framing for application + * messages. Each of these ciphertexts was produced by openmls + * (the MLS backend under MDK / whitenoise); Amethyst must parse the * * struct { - * opaque application_data; // varint-prefixed payload - * FramedContentAuthData auth; // signature over FramedContentTBS - * opaque padding[N]; // zero-padding + * opaque application_data; // varint-prefixed payload + * opaque signature; // FramedContentTBS signature + * opaque padding[N]; // zero padding * } PrivateMessageContent; * - * Amethyst ↔ Amethyst round-trips pass (both sides omit framing), but - * when openmls/MDK/whitenoise sends a PrivateMessage Amethyst's - * `.decrypt` returns the entire PrivateMessageContent serialization — - * so plaintext comparisons fail (we see `0x0a 'H' 'e' ... auth... padding` - * instead of `'Hello Bob!'`). - * - * Full fix requires: (a) sign FramedContentTBS on the send side with a - * MAC over Application/Proposal and a confirmation_tag over Commit; - * (b) parse `application_data` + `FramedContentAuthData` + padding - * on the receive side; (c) verify the signature / confirmation tag. - * Tracked separately — keeping this test as a reproducer. + * layout, verify the Ed25519 signature over FramedContentTBS using the + * sender's LeafNode.signature_key, and return only `application_data`. */ - @Ignore @Test fun testBobDecryptsMdkApplicationMessagesFromAlice() { assertTrue(