feat(marmot): receive standalone PrivateMessage proposals (RFC 9420 §6.3.2)

The PROPOSAL branch of `MlsGroup.decrypt` previously threw
`IllegalStateException("Standalone PrivateMessage proposals not yet
supported")`. That worked in the openmls-as-Whitenoise topology because
MDK emits SelfRemove as a PublicMessage — but any peer using the
AlwaysCiphertext wire-format policy (RFC 9420 §6.3.2) wraps standalone
proposals in PrivateMessage, and we'd hard-fail on first contact.

Implements the PROPOSAL branch symmetrically with the existing COMMIT
branch:

1. Decode the Proposal struct from PrivateMessageContent (no length
   prefix), read signature<V>, drain zero-padding.
2. Restrict to SelfRemove (mirrors `receivePublicMessageProposal`'s
   policy — the only standalone proposal type that needs to ride
   outside a commit; widening is one-line if interop demands it).
3. Verify the FramedContentTBS signature with `wire_format =
   PRIVATE_MESSAGE` against the sender's leaf signature_key.
4. Stage the proposal in `pendingProposals` together with the encoded
   AuthenticatedContent (wire_format ‖ FramedContent ‖ signature) so
   a subsequent inbound commit folding it in by ProposalRef can
   resolve via the §5.2 hash. PrivateMessage AC bytes carry no
   membership_tag — auth = signature only.

Adds a symmetric `encryptProposalAsPrivateMessage` helper (internal,
mirrors `encrypt` for application data) so tests can exercise the
inbound path with a real wire frame produced by the same code path
peers use.

2 new tests: round-trip Bob→Alice SelfRemove via Welcome flow with
independent MlsGroup instances, verifies the proposal lands in
Alice's pending pool with AC bytes captured; and rejection of a
non-SelfRemove standalone PrivateMessage proposal (PSK in the test).

Marmot test suite green; marmot-interop-headless 16/16.

Closes audit gap #1.

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
This commit is contained in:
Claude
2026-04-25 05:00:24 +00:00
parent 8c38394385
commit 57c3265ca0
2 changed files with 265 additions and 1 deletions
@@ -873,6 +873,101 @@ class MlsGroup private constructor(
return MlsMessage.fromPrivateMessage(msg).toTlsBytes()
}
/**
* Encrypt a standalone Proposal as a PrivateMessage (RFC 9420 §6.3.2,
* content_type = PROPOSAL). Mirrors [encrypt] but writes a Proposal
* struct + signature in the PrivateMessageContent instead of
* application_data. The receiver-side counterpart lives in [decrypt]
* under `ContentType.PROPOSAL`.
*
* Used today only for tests — production callers publish SelfRemove
* via [buildSelfRemoveProposalMessage] (PublicMessage) — but exposing
* the encode path keeps the wire format symmetric so a future caller
* that wants a fully-encrypted proposal envelope doesn't have to
* re-derive it.
*/
internal fun encryptProposalAsPrivateMessage(proposal: Proposal): ByteArray {
if (sentKeys.size > MAX_SENT_KEYS) {
val sortedKeys = sentKeys.keys.sorted()
val toRemove = sortedKeys.take(sentKeys.size - MAX_SENT_KEYS)
for (key in toRemove) sentKeys.remove(key)
}
// PROPOSALs ride the handshake ratchet (RFC 9420 §6.3.2), not
// the application ratchet — same as PrivateMessage commits.
val kng = secretTree.nextHandshakeKeyNonce(myLeafIndex)
val reuseGuard = MlsCryptoProvider.randomBytes(REUSE_GUARD_LENGTH)
val guardedNonce = kng.nonce.copyOf()
for (i in 0 until REUSE_GUARD_LENGTH) {
guardedNonce[i] = (guardedNonce[i].toInt() xor reuseGuard[i].toInt()).toByte()
}
val proposalWriter = TlsWriter()
proposal.encodeTls(proposalWriter)
val proposalBytes = proposalWriter.toByteArray()
// FramedContentTBS for a member-sender PROPOSAL over PRIVATE_MESSAGE.
val tbsWriter = TlsWriter()
tbsWriter.putUint16(MlsMessage.MLS_VERSION_10)
tbsWriter.putUint16(WireFormat.PRIVATE_MESSAGE.value)
tbsWriter.putOpaqueVarInt(groupId)
tbsWriter.putUint64(epoch)
encodeSender(tbsWriter, Sender(SenderType.MEMBER, myLeafIndex))
tbsWriter.putOpaqueVarInt(ByteArray(0)) // authenticated_data
tbsWriter.putUint8(ContentType.PROPOSAL.value)
tbsWriter.putBytes(proposalBytes)
tbsWriter.putBytes(groupContext.toTlsBytes())
val signature = MlsCryptoProvider.signWithLabel(signingPrivateKey, "FramedContentTBS", tbsWriter.toByteArray())
// PrivateMessageContent for PROPOSAL: proposal struct (no length
// prefix) || signature<V> || padding.
val pmcWriter = TlsWriter()
pmcWriter.putBytes(proposalBytes)
pmcWriter.putOpaqueVarInt(signature)
val pmcPlaintext = pmcWriter.toByteArray()
val contentAad = buildPrivateContentAAD(groupId, epoch, ContentType.PROPOSAL, ByteArray(0))
val ciphertext = MlsCryptoProvider.aeadEncrypt(kng.key, guardedNonce, contentAad, pmcPlaintext)
val senderDataWriter = TlsWriter()
senderDataWriter.putUint32(myLeafIndex.toLong())
senderDataWriter.putUint32(kng.generation.toLong())
senderDataWriter.putBytes(reuseGuard)
val senderDataPlain = senderDataWriter.toByteArray()
val ciphertextSample = ciphertext.copyOfRange(0, minOf(ciphertext.size, MlsCryptoProvider.HASH_OUTPUT_LENGTH))
val senderDataKey =
MlsCryptoProvider.expandWithLabel(
epochSecrets.senderDataSecret,
"key",
ciphertextSample,
MlsCryptoProvider.AEAD_KEY_LENGTH,
)
val senderDataNonce =
MlsCryptoProvider.expandWithLabel(
epochSecrets.senderDataSecret,
"nonce",
ciphertextSample,
MlsCryptoProvider.AEAD_NONCE_LENGTH,
)
val senderDataAad = buildSenderDataAAD(groupId, epoch, ContentType.PROPOSAL)
val encryptedSenderData =
MlsCryptoProvider.aeadEncrypt(senderDataKey, senderDataNonce, senderDataAad, senderDataPlain)
return MlsMessage
.fromPrivateMessage(
PrivateMessage(
groupId = groupId,
epoch = epoch,
contentType = ContentType.PROPOSAL,
authenticatedData = ByteArray(0),
encryptedSenderData = encryptedSenderData,
ciphertext = ciphertext,
),
).toTlsBytes()
}
/**
* Decrypt an application message from a PrivateMessage.
* Returns null if decryption fails (e.g., corrupted message, wrong epoch).
@@ -1049,7 +1144,90 @@ class MlsGroup private constructor(
}
ContentType.PROPOSAL -> {
throw IllegalStateException("Standalone PrivateMessage proposals not yet supported")
// PrivateMessageContent for a Proposal: the Proposal struct
// (no length prefix) followed by signature<V>, then zero
// padding. There's no membership_tag — PrivateMessage is
// already AEAD-protected by sender membership in the
// secret tree, so RFC 9420 §6.2's HMAC isn't applied.
val proposal = Proposal.decodeTls(pmcReader)
val proposalWriter = TlsWriter()
proposal.encodeTls(proposalWriter)
val proposalBytes = proposalWriter.toByteArray()
val signature = pmcReader.readOpaqueVarInt()
while (pmcReader.hasRemaining) {
require(pmcReader.readBytes(1)[0] == 0.toByte()) {
"PrivateMessageContent padding must be zero"
}
}
// Mirror the same defensive policy as
// [receivePublicMessageProposal]: only SelfRemove proposals
// legitimately arrive standalone today (openmls/mdk emit
// it as a separate PROPOSAL message because non-admins
// can't self-remove via a commit). Reject anything else
// here so a peer can't pre-stage e.g. an Add we never asked
// to fold in. Widen if/when interop demands it.
require(proposal is Proposal.SelfRemove) {
"Only SelfRemove is accepted as a standalone PrivateMessage proposal " +
"(got ${proposal::class.simpleName}); other proposal types must be folded into a commit"
}
// Verify the FramedContent signature with wire_format =
// PRIVATE_MESSAGE (not PUBLIC_MESSAGE) so the TBS bytes
// match what the sender signed.
val tbsWriter = TlsWriter()
tbsWriter.putUint16(MlsMessage.MLS_VERSION_10)
tbsWriter.putUint16(WireFormat.PRIVATE_MESSAGE.value)
tbsWriter.putOpaqueVarInt(privMsg.groupId)
tbsWriter.putUint64(privMsg.epoch)
encodeSender(tbsWriter, Sender(SenderType.MEMBER, senderLeafIndex))
tbsWriter.putOpaqueVarInt(privMsg.authenticatedData)
tbsWriter.putUint8(ContentType.PROPOSAL.value)
tbsWriter.putBytes(proposalBytes)
tbsWriter.putBytes(groupContext.toTlsBytes()) // member sender appends context
val tbs = tbsWriter.toByteArray()
val senderLeaf =
requireNotNull(tree.getLeaf(senderLeafIndex)) {
"Sender leaf is blank at index $senderLeafIndex"
}
require(
MlsCryptoProvider.verifyWithLabel(
senderLeaf.signatureKey,
"FramedContentTBS",
tbs,
signature,
),
) { "FramedContentTBS signature verification failed on PrivateMessage proposal" }
// RFC 9420 §5.2: ProposalRef hashes the encoded
// AuthenticatedContent. For PrivateMessage proposals the
// AC carries no membership_tag — auth = signature only.
// Stash these bytes so a future commit referencing this
// proposal by hash resolves against our pool.
val acWriter = TlsWriter()
acWriter.putUint16(WireFormat.PRIVATE_MESSAGE.value)
acWriter.putOpaqueVarInt(privMsg.groupId)
acWriter.putUint64(privMsg.epoch)
encodeSender(acWriter, Sender(SenderType.MEMBER, senderLeafIndex))
acWriter.putOpaqueVarInt(privMsg.authenticatedData)
acWriter.putUint8(ContentType.PROPOSAL.value)
acWriter.putBytes(proposalBytes)
acWriter.putOpaqueVarInt(signature)
pendingProposals.add(
PendingProposal(
proposal = proposal,
senderLeafIndex = senderLeafIndex,
authenticatedContentBytes = acWriter.toByteArray(),
),
)
return DecryptedMessage(
senderLeafIndex = senderLeafIndex,
contentType = privMsg.contentType,
content = proposalBytes,
epoch = privMsg.epoch,
)
}
}
}
@@ -551,6 +551,92 @@ class MarmotMipBehaviorTest {
)
}
// ----------------------------------------------------------------------
// RFC 9420 §6.3.2 standalone PrivateMessage proposal RX
// ----------------------------------------------------------------------
/**
* Round-trip: Bob (member 1) encrypts a SelfRemove as a
* PrivateMessage PROPOSAL, Alice (admin, member 0) decrypts it.
* Alice's pending pool picks it up with the AC bytes captured for
* RFC 9420 §5.2 ProposalRef matching, so a follow-on commit
* referencing the proposal by hash resolves.
*/
@Test
fun decrypt_acceptsStandaloneSelfRemoveAsPrivateMessage() =
runBlocking<Unit> {
val (alice, bob) = build2MemberGroupWithBobJoined()
val proposal =
com.vitorpamplona.quartz.marmot.mls.messages
.Proposal
.SelfRemove()
val before = alice.pendingProposalsSnapshot().size
val wireBytes = bob.encryptProposalAsPrivateMessage(proposal)
val result = alice.decrypt(wireBytes)
assertEquals(
com.vitorpamplona.quartz.marmot.mls.framing.ContentType.PROPOSAL,
result.contentType,
)
assertEquals(bob.leafIndex, result.senderLeafIndex)
val after = alice.pendingProposalsSnapshot()
assertEquals(before + 1, after.size, "decrypt must stage the proposal in pending pool")
val staged = after.last()
assertIs<com.vitorpamplona.quartz.marmot.mls.messages.Proposal.SelfRemove>(staged.proposal)
assertEquals(bob.leafIndex, staged.senderLeafIndex)
assertNotNull(
staged.authenticatedContentBytes,
"PrivateMessage proposal must capture AC bytes for RFC 9420 §5.2 ProposalRef matching",
)
assertTrue(staged.authenticatedContentBytes.isNotEmpty())
}
/**
* Standalone PrivateMessage proposals are restricted to SelfRemove
* (mirrors `receivePublicMessageProposal`'s policy). A peer that
* tries to pre-stage e.g. a PSK proposal must be rejected, even
* though the AEAD layer accepted the frame.
*/
@Test
fun decrypt_rejectsNonSelfRemovePrivateMessageProposal() =
runBlocking<Unit> {
val (alice, bob) = build2MemberGroupWithBobJoined()
val proposal =
com.vitorpamplona.quartz.marmot.mls.messages
.Proposal
.Psk(pskType = 1, pskId = ByteArray(16) { 0xAB.toByte() }, pskNonce = ByteArray(16))
val wireBytes = bob.encryptProposalAsPrivateMessage(proposal)
val ex =
assertFailsWith<IllegalArgumentException> {
alice.decrypt(wireBytes)
}
assertTrue(
ex.message!!.contains("Only SelfRemove"),
"rejection must explain the policy: ${ex.message}",
)
}
/**
* Build a two-member group via the real Welcome flow so Alice and
* Bob have INDEPENDENT [MlsGroup] instances (independent secret-tree
* consumed-generation sets) required for any test that
* encrypts on one side and decrypts on the other.
*/
private suspend fun build2MemberGroupWithBobJoined(): Pair<MlsGroup, MlsGroup> {
val manager = createGroupManager()
manager.createGroup(groupId, aliceId.hexToByteArray())
val bobBundle = createStandaloneKeyPackage(bobId)
val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
val alice = manager.getGroup(groupId)!!
val welcomeBytes =
requireNotNull(commitResult.welcomeBytes) { "addMember must produce a Welcome" }
val bob = MlsGroup.processWelcome(welcomeBytes, bobBundle)
return alice to bob
}
// ----------------------------------------------------------------------
// DoS guards: forward-ratchet cap + PrivateMessage size caps
// ----------------------------------------------------------------------