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 + } +}