From 38fc66d43da4a2c417d5e0a7536d406267575da8 Mon Sep 17 00:00:00 2001 From: jeremyd <4072+jeremyd@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:21:18 +0000 Subject: [PATCH] feat(marmot): send and return authenticated_data on application messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MLS application messages carry an authenticated_data field (RFC 9420 §6.3.2): sent in the clear and bound to the message by the AEAD and the signature. Quartz verified it on receive but always sent it empty and never returned it, so an application could not use it, e.g. to bind the sender's account key to the message. encrypt() takes an optional authenticatedData, and DecryptedMessage returns it. Without the argument nothing changes on the wire. --- .../quartz/marmot/mls/group/MlsGroup.kt | 24 ++++-- .../marmot/mls/group/AuthenticatedDataTest.kt | 73 +++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt 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 adbf3a6c9b..8a09a870f9 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 @@ -1116,8 +1116,15 @@ class MlsGroup private constructor( * * The signature is computed with `SignWithLabel(., "FramedContentTBS", * FramedContentTBS)` using the member's signature private key. + * + * [authenticatedData] is sent in the clear as the message's + * `authenticated_data` (RFC 9420 §6.3.2). It is bound to the message by the + * AEAD and the signature, and returned by [decrypt]. */ - fun encrypt(plaintext: ByteArray): ByteArray { + fun encrypt( + plaintext: ByteArray, + authenticatedData: ByteArray = ByteArray(0), + ): ByteArray { // Trim sentKeys if it grows too large if (sentKeys.size > MAX_SENT_KEYS) { val sortedKeys = sentKeys.keys.sorted() @@ -1146,7 +1153,7 @@ class MlsGroup private constructor( groupId = groupId, epoch = epoch, senderLeafIndex = myLeafIndex, - authenticatedData = ByteArray(0), + authenticatedData = authenticatedData, applicationData = plaintext, groupContext = groupContext, ), @@ -1162,7 +1169,7 @@ class MlsGroup private constructor( val pmcPlaintext = pmcWriter.toByteArray() // Build PrivateContentAAD (RFC 9420 §6.3.2) - val contentAad = buildPrivateContentAAD(groupId, epoch, ContentType.APPLICATION, ByteArray(0)) + val contentAad = buildPrivateContentAAD(groupId, epoch, ContentType.APPLICATION, authenticatedData) val ciphertext = MlsCryptoProvider.aeadEncrypt(kng.key, guardedNonce, contentAad, pmcPlaintext) // Build sender data plaintext: leaf_index || generation || reuse_guard @@ -1200,7 +1207,7 @@ class MlsGroup private constructor( groupId = groupId, epoch = epoch, contentType = ContentType.APPLICATION, - authenticatedData = ByteArray(0), + authenticatedData = authenticatedData, encryptedSenderData = encryptedSenderData, ciphertext = ciphertext, ) @@ -1442,6 +1449,7 @@ class MlsGroup private constructor( contentType = privMsg.contentType, content = applicationData, epoch = privMsg.epoch, + authenticatedData = privMsg.authenticatedData, ) } @@ -1475,6 +1483,7 @@ class MlsGroup private constructor( contentType = privMsg.contentType, content = commitBytes, epoch = privMsg.epoch, + authenticatedData = privMsg.authenticatedData, ) } @@ -1562,6 +1571,7 @@ class MlsGroup private constructor( contentType = privMsg.contentType, content = proposalBytes, epoch = privMsg.epoch, + authenticatedData = privMsg.authenticatedData, ) } } @@ -4563,19 +4573,23 @@ data class DecryptedMessage( val contentType: ContentType, val content: ByteArray, val epoch: Long, + /** The message's `authenticated_data` (RFC 9420 §6.3.2), verified by the AEAD. */ + val authenticatedData: ByteArray = ByteArray(0), ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is DecryptedMessage) return false return senderLeafIndex == other.senderLeafIndex && content.contentEquals(other.content) && - epoch == other.epoch + epoch == other.epoch && + authenticatedData.contentEquals(other.authenticatedData) } override fun hashCode(): Int { var result = senderLeafIndex result = 31 * result + content.contentHashCode() result = 31 * result + epoch.hashCode() + result = 31 * result + authenticatedData.contentHashCode() return result } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt new file mode 100644 index 0000000000..7a3385da12 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mls.group + +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage +import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFails + +/** `authenticated_data` on application messages (RFC 9420 §6.3.2): sent in the clear, bound by the AEAD. */ +class AuthenticatedDataTest { + private val aad = "sender-binding".encodeToByteArray() + + @Test + fun theReceiverGetsTheSendersAuthenticatedData() { + val (alice, bob) = twoMemberGroup() + + val received = bob.decrypt(alice.encrypt("hi".encodeToByteArray(), aad)) + + assertContentEquals("hi".encodeToByteArray(), received.content) + assertContentEquals(aad, received.authenticatedData) + } + + @Test + fun withoutItTheAuthenticatedDataIsEmpty() { + val (alice, bob) = twoMemberGroup() + + val received = bob.decrypt(alice.encrypt("hi".encodeToByteArray())) + + assertEquals(0, received.authenticatedData.size) + } + + @Test + fun alteredAuthenticatedDataFailsToDecrypt() { + val (alice, bob) = twoMemberGroup() + + val sent = MlsMessage.decodeTls(TlsReader(alice.encrypt("hi".encodeToByteArray(), aad))) + val message = PrivateMessage.decodeTls(TlsReader(sent.payload)) + val altered = MlsMessage.fromPrivateMessage(message.copy(authenticatedData = "someone-else".encodeToByteArray())) + + assertFails { bob.decrypt(altered.toTlsBytes()) } + } + + private fun twoMemberGroup(): Pair { + val alice = MlsGroup.create("11".repeat(32).hexToByteArray()) + val bobBundle = alice.createKeyPackage("22".repeat(32).hexToByteArray(), ByteArray(0)) + val result = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(result.welcomeBytes!!, bobBundle) + return alice to bob + } +}