fix(marmot): apply RFC 9420 PrivateMessageContent framing to app messages
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<V>; // varint-prefixed payload
opaque signature<V>; // 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<V> || signature<V>
(zero-length padding) as the AEAD plaintext.
* decrypt parses application_data<V> and signature<V> 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
This commit is contained in:
+110
-4
@@ -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<V>;
|
||||
* FramedContentAuthData auth; // = opaque signature<V>
|
||||
* 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<V>
|
||||
pmcWriter.putOpaqueVarInt(signature) // FramedContentAuthData.signature<V>
|
||||
// (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<V>
|
||||
// GroupContext (member sender)
|
||||
groupContext.encodeTls(w)
|
||||
return w.toByteArray()
|
||||
}
|
||||
|
||||
// --- Commit Processing ---
|
||||
|
||||
/**
|
||||
|
||||
+8
-20
@@ -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<V>; // varint-prefixed payload
|
||||
* FramedContentAuthData auth; // signature over FramedContentTBS
|
||||
* opaque padding[N]; // zero-padding
|
||||
* opaque application_data<V>; // varint-prefixed payload
|
||||
* opaque signature<V>; // 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<V>` + `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(
|
||||
|
||||
Reference in New Issue
Block a user