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:
+179
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+86
@@ -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
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user