feat(marmot): send and return authenticated_data on application messages

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.
This commit is contained in:
jeremyd
2026-09-23 20:21:18 +00:00
parent 715f6dc877
commit 38fc66d43d
2 changed files with 92 additions and 5 deletions
@@ -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
}
}
@@ -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<MlsGroup, MlsGroup> {
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
}
}