Merge pull request #2477 from vitorpamplona/claude/fix-marmot-welcome-event-W66a8

Fix MLS spec compliance issues in commit path and message encryption
This commit is contained in:
Vitor Pamplona
2026-04-21 13:27:35 -04:00
committed by GitHub
25 changed files with 2360 additions and 100 deletions
@@ -134,7 +134,9 @@ object Hpke {
kemContext: ByteArray,
): ByteArray {
val suiteId = KEM_SUITE_ID
val prk = labeledExtract(suiteId, ByteArray(0), "shared_secret", dh)
// RFC 9180 §4.1: eae_prk = LabeledExtract("", "eae_prk", dh);
// shared_secret = LabeledExpand(eae_prk, "shared_secret", kem_context, Nsecret).
val prk = labeledExtract(suiteId, ByteArray(0), "eae_prk", dh)
return labeledExpand(suiteId, prk, "shared_secret", kemContext, N_SECRET)
}
@@ -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
@@ -412,8 +413,15 @@ class MlsGroup private constructor(
val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) }
// Check if we need an UpdatePath (required unless only SelfRemove)
val needsPath = proposals.any { it.proposal !is Proposal.SelfRemove }
// Check if we need an UpdatePath. RFC 9420 §12.4.1: the path value
// MUST be populated if the proposal list is empty (pure forward-
// secrecy / self-update commit) or if it contains any Update or
// Remove proposal. A commit whose only non-SelfRemove proposals are
// Adds MAY omit the path — we still include one for extra forward
// secrecy, which is spec-compliant.
val needsPath =
proposals.isEmpty() ||
proposals.any { it.proposal !is Proposal.SelfRemove }
// Apply proposals to tree FIRST (RFC 9420 Section 12.4.2)
// Order: Updates/Removes first, then Adds (so blank slots are freed before reuse)
@@ -467,18 +475,58 @@ class MlsGroup private constructor(
UpdatePathNode(pathKey.publicKey, encryptedSecrets)
}
// Capture sibling tree hashes BEFORE applying the UpdatePath —
// parent_hash computation (RFC 9420 §7.9.2) uses the
// ORIGINAL sibling-subtree tree hashes.
val preUpdateSiblingHashes = capturePreUpdateSiblingHashes(myLeafIndex)
// Apply the UpdatePath to our own tree first so the parent
// nodes carry the new encryption keys. Their parent_hash
// fields are filled in next.
tree.applyUpdatePath(myLeafIndex, pathNodes)
// Compute parent_hash for every direct-path parent node and
// for the committer's leaf (RFC 9420 §7.9.2). Without this
// chain, spec-strict peers (ts-mls, OpenMLS) reject the
// Welcome with "Unable to verify parent hash".
val (parentNodeHashes, leafParentHash) =
computeSenderParentHashes(myLeafIndex, preUpdateSiblingHashes)
// Patch each parent node with its computed parent_hash so
// subsequent treeHash / serialization uses the final values.
val directPath = BinaryTree.directPath(myLeafIndex, tree.leafCount)
for (nodeIdx in directPath) {
val existing = tree.getNode(nodeIdx)
if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
tree.setParent(
nodeIdx,
existing.parentNode.copy(
parentHash = parentNodeHashes[nodeIdx] ?: ByteArray(0),
),
)
}
}
// If a signing-key rotation is pending (from
// proposeSigningKeyRotation), the UpdatePath's leaf must be
// sealed with the NEW signing identity — otherwise the
// receiver's copy of our leaf keeps the pre-rotation
// signature_key and every post-commit FramedContentTBS
// signature we mint fails to verify.
val effectiveSigningKey = pendingSigningKey ?: signingPrivateKey
val newEncKp = X25519.generateKeyPair()
val newLeafNode =
buildLeafNode(
encryptionKey = newEncKp.publicKey,
signatureKey = Ed25519.publicFromPrivate(signingPrivateKey),
signatureKey = Ed25519.publicFromPrivate(effectiveSigningKey),
identity =
(tree.getLeaf(myLeafIndex)?.credential as? Credential.Basic)?.identity
?: ByteArray(0),
source = LeafNodeSource.COMMIT,
signingKey = signingPrivateKey,
signingKey = effectiveSigningKey,
groupId = groupId,
leafIndex = myLeafIndex,
parentHash = leafParentHash,
)
encryptionPrivateKey = newEncKp.privateKey
@@ -491,11 +539,6 @@ class MlsGroup private constructor(
val commit = Commit(proposalOrRefs, updatePath)
// Apply UpdatePath to tree
if (updatePath != null) {
tree.applyUpdatePath(myLeafIndex, updatePath.nodes)
}
// Advance epoch
val commitSecret =
if (pathSecrets.isNotEmpty()) {
@@ -577,7 +620,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 +652,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()
@@ -610,8 +687,9 @@ class MlsGroup private constructor(
senderDataWriter.putBytes(reuseGuard)
val senderDataPlain = senderDataWriter.toByteArray()
// Derive sender data key/nonce using ciphertext sample (RFC 9420 §6.3.1)
val ciphertextSample = ciphertext.copyOfRange(0, minOf(ciphertext.size, MlsCryptoProvider.AEAD_KEY_LENGTH))
// Derive sender data key/nonce using ciphertext sample (RFC 9420 §6.3.2:
// "the first KDF.Nh bytes of the ciphertext").
val ciphertextSample = ciphertext.copyOfRange(0, minOf(ciphertext.size, MlsCryptoProvider.HASH_OUTPUT_LENGTH))
val senderDataKey =
MlsCryptoProvider.expandWithLabel(
epochSecrets.senderDataSecret,
@@ -627,7 +705,7 @@ class MlsGroup private constructor(
MlsCryptoProvider.AEAD_NONCE_LENGTH,
)
// Build SenderDataAAD (RFC 9420 §6.3.1)
// Build SenderDataAAD (RFC 9420 §6.3.2)
val senderDataAad = buildSenderDataAAD(groupId, epoch, ContentType.APPLICATION)
val encryptedSenderData =
MlsCryptoProvider.aeadEncrypt(senderDataKey, senderDataNonce, senderDataAad, senderDataPlain)
@@ -676,8 +754,11 @@ class MlsGroup private constructor(
}
// Derive sender data key/nonce using ciphertext sample (RFC 9420 §6.3.1)
// RFC 9420 §6.3.2: ciphertext_sample is the first KDF.Nh bytes
// (32 for HKDF-SHA256), not AEAD.Nk (16). Using AEAD.Nk here made
// sender-data decryption fail against every spec-compliant sender.
val ciphertextSample =
privMsg.ciphertext.copyOfRange(0, minOf(privMsg.ciphertext.size, MlsCryptoProvider.AEAD_KEY_LENGTH))
privMsg.ciphertext.copyOfRange(0, minOf(privMsg.ciphertext.size, MlsCryptoProvider.HASH_OUTPUT_LENGTH))
val senderDataKey =
MlsCryptoProvider.expandWithLabel(
epochSecrets.senderDataSecret,
@@ -727,16 +808,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 ---
/**
@@ -830,6 +982,24 @@ class MlsGroup private constructor(
"Parent hash verification failed for UpdatePath"
}
// After verification, patch the computed parent_hash values into
// the direct-path parent nodes. The sender's tree has these
// values filled in; receivers must match for treeHash() to agree
// (and thus for the epoch key schedule to converge).
val (recvParentHashes, _) =
computeSenderParentHashes(senderLeafIndex, preUpdateSiblingHashes)
for (nodeIdx in BinaryTree.directPath(senderLeafIndex, tree.leafCount)) {
val existing = tree.getNode(nodeIdx)
if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
tree.setParent(
nodeIdx,
existing.parentNode.copy(
parentHash = recvParentHashes[nodeIdx] ?: ByteArray(0),
),
)
}
}
// Decrypt path secret from our copath node
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
val myPath = BinaryTree.directPath(myLeafIndex, tree.leafCount)
@@ -1052,9 +1222,102 @@ class MlsGroup private constructor(
): ByteArray = buildConfirmedTranscriptHashInput(commit, senderLeafIndex, groupId, epoch)
/**
* Capture sibling tree hashes BEFORE the UpdatePath is applied.
* Returns a map of direct-path-index to sibling tree hash.
* Compute the RFC 9420 §7.9.2 parent_hash chain for a commit we are
* producing: returns the parent_hash value that should be stored in
* each parent node on our direct path, plus the parent_hash value
* that belongs in the committer's LeafNode (for its TBS signature).
*
* Must be called AFTER [RatchetTree.applyUpdatePath] (so parent
* nodes carry the new encryption keys) and with [preUpdateSiblingHashes]
* captured BEFORE applyUpdatePath (so the "original sibling tree hash"
* really is from the pre-update tree).
*/
private fun computeSenderParentHashes(
senderLeafIndex: Int,
preUpdateSiblingHashes: Map<Int, ByteArray>,
): Pair<Map<Int, ByteArray>, ByteArray> = computeSenderParentHashes(tree, senderLeafIndex, preUpdateSiblingHashes)
private fun computeSenderParentHashes(
tree: com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree,
senderLeafIndex: Int,
preUpdateSiblingHashes: Map<Int, ByteArray>,
): Pair<Map<Int, ByteArray>, ByteArray> {
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
if (directPath.isEmpty()) return Pair(emptyMap(), ByteArray(0))
val hashes = mutableMapOf<Int, ByteArray>()
// Root's parent_hash is empty by convention (RFC 9420 §7.9.2).
hashes[directPath.last()] = ByteArray(0)
// Walk top-down (root has no parent → already set). For each node
// X below root, parent_hash(X) = Hash(encode(ParentHashInput{
// encryption_key = parent(X).encryption_key,
// parent_hash = parent(X).parent_hash,
// original_sibling_tree_hash = tree_hash(sibling(X))_preUpdate,
// })).
for (i in directPath.size - 2 downTo 0) {
val xIdx = directPath[i]
val parentIdx = directPath[i + 1]
val parentNode = tree.getNode(parentIdx)
if (parentNode !is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
hashes[xIdx] = ByteArray(0)
continue
}
val siblingTreeHash =
preUpdateSiblingHashes[i + 1]
?: error("missing pre-update sibling tree hash at level ${i + 1}")
hashes[xIdx] =
MlsCryptoProvider.hash(
encodeParentHashInput(
encryptionKey = parentNode.parentNode.encryptionKey,
parentHash = hashes[parentIdx] ?: ByteArray(0),
originalSiblingTreeHash = siblingTreeHash,
),
)
}
// The committer's leaf carries parent_hash(parent(leaf)) = parent_hash
// computed with the immediate parent's (directPath[0]) fields.
val immediateParentIdx = directPath.first()
val immediateParent = tree.getNode(immediateParentIdx)
val leafParentHash =
if (immediateParent is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
val siblingTreeHash =
preUpdateSiblingHashes[0]
?: error("missing pre-update sibling tree hash at leaf level")
MlsCryptoProvider.hash(
encodeParentHashInput(
encryptionKey = immediateParent.parentNode.encryptionKey,
parentHash = hashes[immediateParentIdx] ?: ByteArray(0),
originalSiblingTreeHash = siblingTreeHash,
),
)
} else {
ByteArray(0)
}
return Pair(hashes, leafParentHash)
}
/**
* Serialize RFC 9420 §7.9.2 ParentHashInput:
* struct {
* HPKEPublicKey encryption_key;
* opaque parent_hash<V>;
* opaque original_sibling_tree_hash<V>;
* } ParentHashInput;
*/
private fun encodeParentHashInput(
encryptionKey: ByteArray,
parentHash: ByteArray,
originalSiblingTreeHash: ByteArray,
): ByteArray {
val w = TlsWriter()
w.putOpaqueVarInt(encryptionKey)
w.putOpaqueVarInt(parentHash)
w.putOpaqueVarInt(originalSiblingTreeHash)
return w.toByteArray()
}
private fun capturePreUpdateSiblingHashes(senderLeafIndex: Int): Map<Int, ByteArray> {
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
val nodeCount = BinaryTree.nodeCount(tree.leafCount)
@@ -1090,47 +1353,16 @@ class MlsGroup private constructor(
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
if (directPath.isEmpty()) return true
// RFC 9420 §7.9.2 requires COMMIT leaf nodes to carry a non-empty
// parent_hash chained up to the root. This implementation does NOT
// yet compute that chain on the sending side (applyUpdatePath sets
// parent_hash = ByteArray(0) for every parent node on the path).
// Until the chain is implemented, treat an empty leaf parent_hash
// as "self-produced commit, chain not computed" and skip the chain
// verification. A compliant peer that does compute parent_hash will
// still be validated against our computed chain below — so if they
// disagree with us, we'll reject them.
//
// TODO: compute parent_hash per RFC 9420 §7.9.2 in [commit] and then
// tighten this verifier back to "MUST be non-empty".
val leafParentHash = updatePath.leafNode.parentHash
if (leafParentHash == null || leafParentHash.isEmpty()) return true
// Walk up the direct path, verifying each parent_hash link
var expectedParentHash = leafParentHash
for ((i, pathNodeIdx) in directPath.withIndex()) {
val pathNode = tree.getNode(pathNodeIdx) ?: continue
if (pathNode is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
// Use the pre-update sibling tree hash (captured before UpdatePath was applied)
val siblingHash = preUpdateSiblingHashes[i] ?: continue
// ParentHashInput
val phi = TlsWriter()
phi.putOpaqueVarInt(pathNode.parentNode.encryptionKey)
phi.putOpaqueVarInt(pathNode.parentNode.parentHash)
phi.putOpaqueVarInt(siblingHash)
val computedHash = MlsCryptoProvider.hash(phi.toByteArray())
if (!expectedParentHash.contentEquals(computedHash)) {
return false // Parent hash chain broken
}
// The next level's expected parent_hash is this node's parent_hash
expectedParentHash = pathNode.parentNode.parentHash
}
}
return true
// RFC 9420 §7.9.2: compute what the sender's parent_hash chain
// SHOULD have been given the post-update tree state, then compare
// the leaf's stored parent_hash to the expected value. We can't
// rely on the stored parent_hash of the parent nodes on our own
// tree because applyUpdatePath writes them as placeholder empty
// bytes — the sender never transmits them on the wire.
val (_, expectedLeafParentHash) =
computeSenderParentHashes(senderLeafIndex, preUpdateSiblingHashes)
val leafParentHash = updatePath.leafNode.parentHash ?: ByteArray(0)
return leafParentHash.contentEquals(expectedLeafParentHash)
}
private fun verifyLeafNodeSignature(
@@ -1472,12 +1704,13 @@ class MlsGroup private constructor(
)
val gsBytes = groupSecrets.toTlsBytes()
// HPKE-encrypt to the member's init_key
// HPKE-encrypt to the member's init_key. Per RFC 9420
// §12.4.3.1, the HPKE context is the encrypted_group_info.
val hpkeCt =
MlsCryptoProvider.encryptWithLabel(
kp.initKey,
"Welcome",
ByteArray(0),
encryptedGroupInfo,
gsBytes,
)
@@ -1500,7 +1733,11 @@ class MlsGroup private constructor(
companion object {
private const val MAX_SENT_KEYS = 10_000
private const val REUSE_GUARD_LENGTH = 4
private const val RATCHET_TREE_EXTENSION_TYPE = 0x0001
// RFC 9420 §13.3 IANA registry: 0x0002 is ratchet_tree.
// (0x0001 is application_id — using it here makes Welcomes
// unreadable to OpenMLS/MDK/whitenoise.)
private const val RATCHET_TREE_EXTENSION_TYPE = 0x0002
/**
* Wrap a raw [Commit] (as [commitBytes]) in an MlsMessage(PublicMessage(...))
@@ -1557,6 +1794,56 @@ class MlsGroup private constructor(
return writer.toByteArray()
}
/**
* Companion-accessible clone of [computeSenderParentHashes].
* Used by [externalJoin] which builds its own tree locally
* (without constructing an [MlsGroup] first).
*/
private fun computeExternalSenderParentHashes(
tree: com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree,
senderLeafIndex: Int,
preUpdateSiblingHashes: Map<Int, ByteArray>,
): Pair<Map<Int, ByteArray>, ByteArray> {
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
if (directPath.isEmpty()) return Pair(emptyMap(), ByteArray(0))
val hashes = mutableMapOf<Int, ByteArray>()
hashes[directPath.last()] = ByteArray(0)
for (i in directPath.size - 2 downTo 0) {
val xIdx = directPath[i]
val parentIdx = directPath[i + 1]
val parentNode = tree.getNode(parentIdx)
if (parentNode !is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
hashes[xIdx] = ByteArray(0)
continue
}
val siblingTreeHash =
preUpdateSiblingHashes[i + 1]
?: error("missing pre-update sibling tree hash at level ${i + 1}")
val w = TlsWriter()
w.putOpaqueVarInt(parentNode.parentNode.encryptionKey)
w.putOpaqueVarInt(hashes[parentIdx] ?: ByteArray(0))
w.putOpaqueVarInt(siblingTreeHash)
hashes[xIdx] = MlsCryptoProvider.hash(w.toByteArray())
}
val immediateParentIdx = directPath.first()
val immediateParent = tree.getNode(immediateParentIdx)
val leafParentHash =
if (immediateParent is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
val siblingTreeHash =
preUpdateSiblingHashes[0]
?: error("missing pre-update sibling tree hash at leaf level")
val w = TlsWriter()
w.putOpaqueVarInt(immediateParent.parentNode.encryptionKey)
w.putOpaqueVarInt(hashes[immediateParentIdx] ?: ByteArray(0))
w.putOpaqueVarInt(siblingTreeHash)
MlsCryptoProvider.hash(w.toByteArray())
} else {
ByteArray(0)
}
return Pair(hashes, leafParentHash)
}
/**
* Compute confirmation_tag = MAC(confirmation_key, confirmed_transcript_hash).
* Static version usable from companion object factory methods.
@@ -1570,8 +1857,16 @@ class MlsGroup private constructor(
return mac.doFinal()
}
private const val REQUIRED_CAPABILITIES_EXTENSION_TYPE = 0x0002
private const val EXTERNAL_PUB_EXTENSION_TYPE = 0x0003
// RFC 9420 §13.3 IANA registry: 0x0003 is required_capabilities.
// (0x0002 is ratchet_tree — putting it here makes GroupContext
// unreadable to OpenMLS/MDK, which type-validates extensions by
// context.)
private const val REQUIRED_CAPABILITIES_EXTENSION_TYPE = 0x0003
// RFC 9420 §13.3 IANA registry: 0x0004 is external_pub.
// (0x0003 is required_capabilities — using it here makes
// external-join GroupInfos unreadable to OpenMLS/MDK.)
private const val EXTERNAL_PUB_EXTENSION_TYPE = 0x0004
private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0004
/** MLS self_remove proposal type (MIP-00 / MIP-03). */
@@ -1715,12 +2010,13 @@ class MlsGroup private constructor(
welcome.secrets.find { it.newMember.contentEquals(myRef) }
?: throw IllegalArgumentException("Welcome does not contain secrets for our KeyPackage")
// HPKE-decrypt group secrets
// HPKE-decrypt group secrets. Per RFC 9420 §12.4.3.1, the HPKE
// context is the encrypted_group_info field of the Welcome.
val gsBytes =
MlsCryptoProvider.decryptWithLabel(
bundle.initPrivateKey,
"Welcome",
ByteArray(0),
welcome.encryptedGroupInfo,
mySecrets.encryptedGroupSecrets.kemOutput,
mySecrets.encryptedGroupSecrets.ciphertext,
)
@@ -1906,8 +2202,10 @@ class MlsGroup private constructor(
// Build ExternalInit proposal
val externalInitProposal = Proposal.ExternalInit(kemOutput)
// Add ourselves to the tree
val leafNode =
// Placeholder leaf so we can claim our slot and compute the
// direct path for sibling-hash capture. The final leaf (with
// parent_hash filled in + fresh signature) replaces this below.
val placeholderLeaf =
buildLeafNode(
encryptionKey = encKp.publicKey,
signatureKey = sigKp.publicKey,
@@ -1915,9 +2213,23 @@ class MlsGroup private constructor(
source = LeafNodeSource.COMMIT,
signingKey = sigKp.privateKey,
groupId = groupContext.groupId,
leafIndex = tree.leafCount, // We'll be the next leaf
leafIndex = tree.leafCount,
)
val myLeafIndex = tree.addLeaf(leafNode)
val myLeafIndex = tree.addLeaf(placeholderLeaf)
// Capture sibling tree hashes BEFORE applying the UpdatePath.
val preUpdateSiblingHashes =
run {
val dp = BinaryTree.directPath(myLeafIndex, tree.leafCount)
val nc = BinaryTree.nodeCount(tree.leafCount)
val map = mutableMapOf<Int, ByteArray>()
for ((i, _) in dp.withIndex()) {
val childIdx =
if (i == 0) BinaryTree.leafToNode(myLeafIndex) else dp[i - 1]
map[i] = tree.treeHashNode(BinaryTree.sibling(childIdx, nc))
}
map
}
// Build UpdatePath for our leaf
val leafSecret = MlsCryptoProvider.randomBytes(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
@@ -1949,8 +2261,39 @@ class MlsGroup private constructor(
UpdatePathNode(pathKey.publicKey, encryptedSecrets)
}
tree.applyUpdatePath(myLeafIndex, pathNodes)
// Compute parent_hash chain + the leaf's parent_hash (RFC 9420
// §7.9.2) on the post-update tree, then patch parent nodes and
// rebuild the committer leaf with the computed parent_hash.
val (extParentHashes, extLeafParentHash) =
computeExternalSenderParentHashes(tree, myLeafIndex, preUpdateSiblingHashes)
for (nodeIdx in BinaryTree.directPath(myLeafIndex, tree.leafCount)) {
val existing = tree.getNode(nodeIdx)
if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) {
tree.setParent(
nodeIdx,
existing.parentNode.copy(
parentHash = extParentHashes[nodeIdx] ?: ByteArray(0),
),
)
}
}
val leafNode =
buildLeafNode(
encryptionKey = encKp.publicKey,
signatureKey = sigKp.publicKey,
identity = identity,
source = LeafNodeSource.COMMIT,
signingKey = sigKp.privateKey,
groupId = groupContext.groupId,
leafIndex = myLeafIndex,
parentHash = extLeafParentHash,
)
tree.setLeaf(myLeafIndex, leafNode)
val updatePath = UpdatePath(leafNode, pathNodes)
tree.applyUpdatePath(myLeafIndex, updatePath.nodes)
// Build Commit
val commit =
@@ -2050,6 +2393,7 @@ class MlsGroup private constructor(
signingKey: ByteArray,
groupId: ByteArray? = null,
leafIndex: Int? = null,
parentHash: ByteArray? = null,
): LeafNode {
val unsigned =
LeafNode(
@@ -2066,6 +2410,7 @@ class MlsGroup private constructor(
} else {
null
},
parentHash = parentHash,
extensions = emptyList(),
signature = ByteArray(0), // Placeholder
)
@@ -189,12 +189,6 @@ class CryptoBasicsInteropTest {
for (v in vectors) {
val ewl = v.encryptWithLabel
// Test HPKE round-trip: encrypt then decrypt with the same key pair.
// The IETF test vector decryption fails due to a platform-specific X25519 DH
// discrepancy (all Python/Java X25519 libs produce a different DH result than
// the Rust implementation that generated the test vector, despite identical
// public key derivation). Our HPKE key schedule is verified correct against
// the IETF RFC 9180 test vectors.
val plaintext = ewl.plaintext.hexToByteArray()
val ciphertext =
MlsCryptoProvider.encryptWithLabel(
@@ -220,4 +214,32 @@ class CryptoBasicsInteropTest {
)
}
}
/**
* Decrypt the IETF KAT directly using the vector's kem_output and
* ciphertext. This is the test that would have caught the HPKE
* "eae_prk" label bug a round-trip is not enough because both sides
* would have been wrong the same way.
*/
@Test
fun testEncryptWithLabelDecryptsIetfVector() {
assertTrue(vectors.isNotEmpty(), "No cipher_suite==1 vectors found")
for (v in vectors) {
val ewl = v.encryptWithLabel
val plaintext =
MlsCryptoProvider.decryptWithLabel(
ewl.priv.hexToByteArray(),
ewl.label,
ewl.context.hexToByteArray(),
ewl.kemOutput.hexToByteArray(),
ewl.ciphertext.hexToByteArray(),
)
assertContentEquals(
ewl.plaintext.hexToByteArray(),
plaintext,
"IETF encrypt_with_label KAT decrypt mismatch for label='${ewl.label}'",
)
}
}
}
@@ -22,13 +22,21 @@ package com.vitorpamplona.quartz.marmot.mls.interop
import com.vitorpamplona.quartz.TestResourceLoader
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.marmot.mls.crypto.X25519
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat
import com.vitorpamplona.quartz.marmot.mls.messages.GroupInfo
import com.vitorpamplona.quartz.marmot.mls.messages.GroupSecrets
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.marmot.mls.messages.Welcome
import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
@@ -91,4 +99,315 @@ class WelcomeInteropTest {
)
}
}
/**
* RFC 7748 §6.1 X25519 known-answer vectors. If any of these fail, our
* DH output disagrees with every spec-compliant implementation (MDK,
* OpenMLS, OpenSSL, BoringSSL, BouncyCastle), which is exactly the
* failure mode that makes interop-Welcome fail with BAD_DECRYPT.
*/
@Test
fun testX25519Rfc7748Vectors() {
val scalar1 = "a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4".hexToByteArray()
val u1 = "e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c".hexToByteArray()
val expect1 = "c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552".hexToByteArray()
assertContentEquals(
expect1,
X25519.dh(scalar1, u1),
"RFC 7748 §6.1 vector 1 mismatch — X25519 DH is non-compliant",
)
val scalar2 = "4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d".hexToByteArray()
val u2 = "e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493".hexToByteArray()
val expect2 = "95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957".hexToByteArray()
assertContentEquals(
expect2,
X25519.dh(scalar2, u2),
"RFC 7748 §6.1 vector 2 mismatch",
)
}
/**
* Sanity check: the init_pub in each Welcome vector's KeyPackage must
* match X25519.publicFromPrivate(init_priv). If they don't, the HPKE
* kem_context (enc || pkRm) computed during decapsulation will not
* match what the sender used, leading to key-schedule divergence.
*/
@Test
fun testWelcomeInitKeyMatchesPrivateKey() {
assertTrue(vectors.isNotEmpty(), "No cipher_suite==1 welcome vectors found")
for ((idx, v) in vectors.withIndex()) {
val kpMsg = MlsMessage.decodeTls(TlsReader(v.keyPackage.hexToByteArray()))
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
val initPriv = v.initPriv.hexToByteArray()
val derivedPub = X25519.publicFromPrivate(initPriv)
assertContentEquals(
kp.initKey,
derivedPub,
"init_key mismatch at welcome vector $idx: KeyPackage.init_key != X25519.publicFromPrivate(init_priv)",
)
}
}
/**
* Compare our X25519 DH against Java's built-in XDH (JDK 11+) using the
* IETF encrypt_with_label vector's priv and kem_output. Any disagreement
* here explains the BAD_DECRYPT on Welcomes from MDK/OpenMLS.
*/
@Test
fun testX25519DhMatchesJavaXdh() {
val priv = "fb1ade7939987ff12a9d620772b1f9f7caeba26f8a3ecea9617d9402cd862444".hexToByteArray()
val kemOut = "0a144e8fbf2d6dcf6fe9d2e2b8aeca5461ff5b0ea9c0ede1040c3dc7ed1dfd1c".hexToByteArray()
val expected = "940f1c6d6e60a066d98c5ab04561ab87118c3fcbcbd68f1734864f9a304a3b38".hexToByteArray()
val ours = X25519.dh(priv, kemOut)
assertContentEquals(expected, ours, "X25519 DH disagrees with Java XDH on IETF vector")
}
/**
* Exercise the HPKE path used to unwrap GroupSecrets from an IETF
* passive-client Welcome vector. This is the exact call site that
* interop-fails against MDK/OpenMLS when the context is wrong.
*/
@Test
fun testWelcomeGroupSecretsDecryptAgainstIetfVector() {
assertTrue(vectors.isNotEmpty(), "No cipher_suite==1 welcome vectors found")
for ((idx, v) in vectors.withIndex()) {
val kpMsg = MlsMessage.decodeTls(TlsReader(v.keyPackage.hexToByteArray()))
assertEquals(WireFormat.KEY_PACKAGE, kpMsg.wireFormat)
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
val myRef = kp.reference()
val welcomeMsg = MlsMessage.decodeTls(TlsReader(v.welcome.hexToByteArray()))
assertEquals(WireFormat.WELCOME, welcomeMsg.wireFormat)
val welcome = Welcome.decodeTls(TlsReader(welcomeMsg.payload))
val mySecrets = welcome.secrets.find { it.newMember.contentEquals(myRef) }
assertTrue(mySecrets != null, "Welcome vector $idx has no secrets for our KeyPackage")
val initPriv = v.initPriv.hexToByteArray()
val gsBytes =
MlsCryptoProvider.decryptWithLabel(
initPriv,
"Welcome",
welcome.encryptedGroupInfo,
mySecrets.encryptedGroupSecrets.kemOutput,
mySecrets.encryptedGroupSecrets.ciphertext,
)
// Should parse cleanly as GroupSecrets
GroupSecrets.decodeTls(TlsReader(gsBytes))
}
}
/**
* Verify each IETF KeyPackage's self-signature (signature over KeyPackageTBS
* using the LeafNode's signature_key). Exercises our Ed25519 + SignContext
* + LeafNode TLS encoding against known-good OpenMLS/mls-rs output.
*/
@Test
fun testIetfKeyPackageSelfSignatureVerifies() {
assertTrue(vectors.isNotEmpty(), "No cipher_suite==1 welcome vectors found")
for ((idx, v) in vectors.withIndex()) {
val kpMsg = MlsMessage.decodeTls(TlsReader(v.keyPackage.hexToByteArray()))
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
assertTrue(
kp.verifySignature(),
"KeyPackage self-signature verification failed at welcome vector $idx",
)
}
}
/**
* Full Welcome GroupInfo path against an IETF vector:
* 1. HPKE-decrypt GroupSecrets using init_priv.
* 2. Derive welcome_key / welcome_nonce from joiner_secret.
* 3. AEAD-decrypt encrypted_group_info.
* 4. Verify GroupInfo signature against welcome.signer_pub.
*
* This is the canonical "can we actually read an OpenMLS Welcome"
* test it touches HPKE key schedule, AEAD (twice), HKDF-ExpandWithLabel,
* GroupInfo TLS decoding, and Ed25519 GroupInfoTBS verification.
*/
@Test
fun testIetfWelcomeFullDecryptAndGroupInfoSignature() {
assertTrue(vectors.isNotEmpty(), "No cipher_suite==1 welcome vectors found")
for ((idx, v) in vectors.withIndex()) {
val kpMsg = MlsMessage.decodeTls(TlsReader(v.keyPackage.hexToByteArray()))
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
val myRef = kp.reference()
val welcomeMsg = MlsMessage.decodeTls(TlsReader(v.welcome.hexToByteArray()))
val welcome = Welcome.decodeTls(TlsReader(welcomeMsg.payload))
val mySecrets =
welcome.secrets.find { it.newMember.contentEquals(myRef) }
?: error("No secrets for our KeyPackage at vector $idx")
val gsBytes =
MlsCryptoProvider.decryptWithLabel(
v.initPriv.hexToByteArray(),
"Welcome",
welcome.encryptedGroupInfo,
mySecrets.encryptedGroupSecrets.kemOutput,
mySecrets.encryptedGroupSecrets.ciphertext,
)
val groupSecrets = GroupSecrets.decodeTls(TlsReader(gsBytes))
// RFC 9420 §8.1 Welcome derivation:
// member_secret = Extract(joiner_secret, psk_secret)
// welcome_secret = DeriveSecret(member_secret, "welcome")
// welcome_key = ExpandWithLabel(welcome_secret, "key", "", AEAD.Nk)
// welcome_nonce = ExpandWithLabel(welcome_secret, "nonce", "", AEAD.Nn)
val pskSecret = ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
val memberSecret =
MlsCryptoProvider.hkdfExtract(groupSecrets.joinerSecret, pskSecret)
val welcomeSecret = MlsCryptoProvider.deriveSecret(memberSecret, "welcome")
val welcomeKey =
MlsCryptoProvider.expandWithLabel(
welcomeSecret,
"key",
ByteArray(0),
MlsCryptoProvider.AEAD_KEY_LENGTH,
)
val welcomeNonce =
MlsCryptoProvider.expandWithLabel(
welcomeSecret,
"nonce",
ByteArray(0),
MlsCryptoProvider.AEAD_NONCE_LENGTH,
)
val groupInfoBytes =
MlsCryptoProvider.aeadDecrypt(
welcomeKey,
welcomeNonce,
ByteArray(0),
welcome.encryptedGroupInfo,
)
val groupInfo = GroupInfo.decodeTls(TlsReader(groupInfoBytes))
assertTrue(
groupInfo.verifySignature(v.signerPub.hexToByteArray()),
"GroupInfo signature verification failed at welcome vector $idx",
)
}
}
/**
* passive-client-welcome.json ships the full ratchet tree and the
* joiner's three private keys. Verify we can at least decrypt the
* Welcome's GroupInfo and find the joiner's own leaf in the tree
* the prerequisite for deriving the initial_epoch_authenticator.
*/
@Test
fun testPassiveClientWelcomeDecryptsGroupInfoAndFindsLeaf() {
// Vectors with external PSKs need a psk_secret derivation we don't
// bother to model in this test — the HPKE/AEAD round-trip is the
// focus. IETF ships passive-client vectors both with and without
// external_psks; we exercise the latter.
val passive: List<PassiveClientVector> =
JsonMapper.jsonInstance
.decodeFromString<List<PassiveClientVector>>(
TestResourceLoader().loadString("mls/passive-client-welcome.json"),
).filter { it.cipherSuite == 1 && it.externalPsks.isEmpty() }
assertTrue(passive.isNotEmpty(), "No cipher_suite==1, PSK-free passive-client-welcome vectors")
for ((idx, v) in passive.withIndex()) {
val kpMsg = MlsMessage.decodeTls(TlsReader(v.keyPackage.hexToByteArray()))
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
val myRef = kp.reference()
val welcomeMsg = MlsMessage.decodeTls(TlsReader(v.welcome.hexToByteArray()))
val welcome = Welcome.decodeTls(TlsReader(welcomeMsg.payload))
val mySecrets =
welcome.secrets.find { it.newMember.contentEquals(myRef) }
?: error("No secrets for our KeyPackage at passive vector $idx")
val gsBytes =
MlsCryptoProvider.decryptWithLabel(
v.initPriv.hexToByteArray(),
"Welcome",
welcome.encryptedGroupInfo,
mySecrets.encryptedGroupSecrets.kemOutput,
mySecrets.encryptedGroupSecrets.ciphertext,
)
// Read joiner_secret directly — GroupSecrets.decodeTls assumes
// psks is a vector of opaque blobs, but IETF passive-client
// vectors with external PSKs encode them as PreSharedKeyID
// structs. The HPKE decrypt is what this test exercises.
val joinerSecret = TlsReader(gsBytes).readOpaqueVarInt()
val pskSecret = ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
val memberSecret = MlsCryptoProvider.hkdfExtract(joinerSecret, pskSecret)
val welcomeSecret = MlsCryptoProvider.deriveSecret(memberSecret, "welcome")
val welcomeKey =
MlsCryptoProvider.expandWithLabel(
welcomeSecret,
"key",
ByteArray(0),
MlsCryptoProvider.AEAD_KEY_LENGTH,
)
val welcomeNonce =
MlsCryptoProvider.expandWithLabel(
welcomeSecret,
"nonce",
ByteArray(0),
MlsCryptoProvider.AEAD_NONCE_LENGTH,
)
val groupInfoBytes =
MlsCryptoProvider.aeadDecrypt(
welcomeKey,
welcomeNonce,
ByteArray(0),
welcome.encryptedGroupInfo,
)
val groupInfo = GroupInfo.decodeTls(TlsReader(groupInfoBytes))
// Ratchet tree may come inline in GroupInfo extensions
// (extension_type = 0x0001) or out-of-band via v.ratchetTree.
// Some vectors carry neither (delivery-service lookup) — in that
// case just assert the decrypt went through cleanly.
val ratchetTreeExt = groupInfo.extensions.find { it.extensionType == 0x0001 }
val treeBytes =
ratchetTreeExt?.extensionData ?: v.ratchetTree?.hexToByteArray()
if (treeBytes == null) continue
val tree = RatchetTree.decodeTls(TlsReader(treeBytes))
// Find our leaf by matching the signature public key from our
// KeyPackage. (The vector's signature_priv is a 32-byte Ed25519
// seed; our Ed25519.publicFromPrivate expects seed || pub, so
// reach through the LeafNode instead.)
val mySigPub = kp.leafNode.signatureKey
var myLeafIndex = -1
for (i in 0 until tree.leafCount) {
val leaf = tree.getLeaf(i)
if (leaf != null && leaf.signatureKey.contentEquals(mySigPub)) {
myLeafIndex = i
break
}
}
assertTrue(
myLeafIndex >= 0,
"Joiner's signature key not found in ratchet tree at passive vector $idx",
)
// GroupInfo signature: signer is a different leaf in the tree.
val signerLeaf = tree.getLeaf(groupInfo.signer)
assertNotNull(
signerLeaf,
"Signer leaf is null at signer=${groupInfo.signer} for passive vector $idx",
)
assertTrue(
groupInfo.verifySignature(signerLeaf.signatureKey),
"GroupInfo signature verification failed for passive vector $idx",
)
}
}
}
@@ -0,0 +1,36 @@
{
"app_messages_alice_to_bob": [
{
"plaintext": "48656c6c6f20426f6221",
"private_message": "000100021050ff04a594bbd0656c322babd997380c000000000000000101001c553ba9c7fc54439e7c237d0df65c3d4c5e77da2f5e22b079d52f822e405d342023fa52ac51b4e7f2f9f23b8a4cc13293df0814593dd8f0ad26a584b6491803fbfdfa6501f7cb9c5766fd94546e4421aab3f858ad146ddf27c8f142c55b92f5e6a27be1f07905ef32d711c5c93ddcbc108fe12f0caeb9e5ca3f053a"
},
{
"plaintext": "5365636f6e64206d65737361676520696e207468652073616d652065706f63682e",
"private_message": "000100021050ff04a594bbd0656c322babd997380c000000000000000101001c10e8f8459b2eebe3ca7caf4c0265d0ea8243c348af766620dcf283594074e22fd558857a43ea86c187db8010e7241a347025bd2dc5726f54068a82522f92f585396623fe4411dc40ce6b99d3f401f308d1778f66daa6acc683c0ac6fd11897da8399818084da3bd3cc9b552ec20e1baff56fff2f4f30675ea107390dc19e93ef2c406d09cb6534ef813b6429ca97857f83e4"
},
{
"plaintext": "556e69636f646520776f726b7320746f6f3a20e2989520e29da4",
"private_message": "000100021050ff04a594bbd0656c322babd997380c000000000000000101001c98b90311c25af7116a89365fed4c44931640624bfb4ab31b9c058df6406d118367be9490eb06221fa8430ca1e29d84fc7bdd02c0638bb2ed2e06713763944ecd69994dd5c048c286e7286904fd780e75ab430b7c90774b2d7e2ecb6370bf5ffc27ab3e0617618f03bfc911c8e86c967874836d0f3ccbcf2c25cbdeefa10d83949de6f288b840a5ba4a6ade"
}
],
"cipher_suite": 1,
"committer": {
"signer_pub": "dc732aad8d681dbcdf9597cd388a6b6ffb120768e726cf49d154648cd57e3fc2"
},
"description": "Alice creates a group and welcomes Bob via openmls 0.8 (MDK's MLS backend).",
"exporter": {
"context": "67726f75702d6576656e74",
"label": "marmot",
"length": 32,
"secret": "49c30cc8f0281598e8482ada2eb4ace234812b0b5677eb2db7b6a66dbdcd6383"
},
"joiner": {
"encryption_priv": "48eb9af0fbf930eb8b29bc5ef66f216264322a5b5f83022379b4b66d2dbd4eb1",
"init_priv": "c36b4147fd5b23240bcb43f5875234fbb5ca77d1030dcddf48b7b159e9a0a862",
"key_package": "000100050001000120a34b15f53e94d35dbddd3c06bc889c2f465745606f4880ecd7c84b3fb1a3bc712080cbfc05d6f56442f58bee38a2aa7dd5761a5fd919696a34bcc1b9a0d375913f2011e883c0229e008874c08c9449a091248cd988efdc32edb3baa4607bd28a0eda000103626f6202000108000100020003004d0000020001010000000069e6f34c000000006a55bf5c004040f49d08107981263e21100f389c606952850f4ad6d09f40282c43aa2b8d7a8176c53e8062903654abfc056efee58e3ed4313591d1d16916fa768b8f1a169ad80103000a0040402002d3545071d2ef10a7cc8663d37e8bd09f0f56a204e07f83489ac5dfa7afbe82da791194a709db6cc9e8128dd0a20fb5a45b5a30245a14c2c4575a86e18105",
"key_package_raw": "0001000120a34b15f53e94d35dbddd3c06bc889c2f465745606f4880ecd7c84b3fb1a3bc712080cbfc05d6f56442f58bee38a2aa7dd5761a5fd919696a34bcc1b9a0d375913f2011e883c0229e008874c08c9449a091248cd988efdc32edb3baa4607bd28a0eda000103626f6202000108000100020003004d0000020001010000000069e6f34c000000006a55bf5c004040f49d08107981263e21100f389c606952850f4ad6d09f40282c43aa2b8d7a8176c53e8062903654abfc056efee58e3ed4313591d1d16916fa768b8f1a169ad80103000a0040402002d3545071d2ef10a7cc8663d37e8bd09f0f56a204e07f83489ac5dfa7afbe82da791194a709db6cc9e8128dd0a20fb5a45b5a30245a14c2c4575a86e18105",
"signature_priv": "a8df2cd3bf838631ed1e6d18ed7eedc67bb1a3d5068d492317a0bc31a40ad588",
"signature_pub": "11e883c0229e008874c08c9449a091248cd988efdc32edb3baa4607bd28a0eda"
},
"welcome": "0001000300014098200f5cb74e0bb41ccd90d7d314ce2eb4f2062d2a1b6679ce83c7c2e049e97c86b22055eac4748f12cdf68b62298eb7d1b484bab6705ca7eaa6f32efbd0be9a6f6a01405422baf660818ff94c383daf88a410669d1b6f86eba50c0c41cf69de73af16bb91942fde3f5d0443873af81f78058469b59e9730e98499f8fa8d6678dff7b049e9f4dc2cd1980a3eacf2cd4669d8ef64b436b4b49742757ca4326bd108511294c9d1063cd28cdcfed7f7852cc5f9c7bb7364b8e48b5fc50e45f2d15f6f8e3e1db6b2b9a405b0996862a50d161ff02b4844e5d5d613f1ac67f191cce9ad3310c7cc9041f02099e8c960a16080b4655e22b12b830d1fab967e08a05e55ec7f1b8c5f963a9c9f64867d3d66c8d87fff63cbc3ee2d4a774f53f0e6cf3de157b77023928c505e8bfaea70390213c4d8bb6a360719675472a3990e2e37073d4bb76379c30e6c1ec69bcb4f5e2440c5feb54e09f2dae6c8eed1de40fb3ea4c782a205044f843a61e90ca8e4aa8cbcac976e089f894ac6235261f9f92e5c5b9460ac51255b754607db0db1457bb7e2c34a9f152a98825fa6c91579b4b2ead6754175c6cc696f70d81e972c9ad16dfd3a9342b0f5a0427b6ab5953d456140a2508e2429fc880514a995580cb2defc27eb910931b0e8d9c548265442e1efb879a8cda465af01f65752e563bdd2187d72aa7956736c19ce739ec2ae43f654a8f22caff7822456af85a58c80b9067e19ff5521d3eff711ae75cbcf0b9185e2816e5af88b68d8f0bb5d4c2e99a362a531a4a2ade352990db2f2caa2b5f5e0f3411fdbc7470fa0603e777aa8974550d93195bee1571b7073a495c7ee22fc2fe0ad50900bdcba5980d51130a2978402009b778a919602f8cce53a6e7c48b1822a8a366971b43c89f3147a48665df6f8982f2d713b2d71dc8bc11dd5b170e767925cb407fc9c23e07b9044addc2b23d2788d36ba780f7d539857855b2682a78c7e90fb4b10ea92bee531733f29b1ae4b36d4a0056194e18db09b1bc28485b7e089a1014df2dede2f17f563fd1eaaad2bb6e5516a9e59b142afc8ae0468897b2eaa57baff66c120cb1d026323dd9bcce28873354a"
}
@@ -0,0 +1,36 @@
{
"cipher_suite": 1,
"description": "Alice creates a group and welcomes Bob via ts-mls 2.0.0-rc.10 (marmot-ts's MLS backend).",
"joiner": {
"init_priv": "f8760fc20168bad612263d5a489c43f683f81b9fed9c082cc47e3382d4fbdd58",
"encryption_priv": "c01e40b015417c063222b9114ea33b79b0ae99cd8010b9b9d77054d45aa3856a",
"signature_priv": "171c188b5fdb9c45fce91d1170ca4074cd5d63467ebcbe992000decf48141298",
"signature_pub": "db0e8d8174cc493194b38970c05553082fff59a15acb02a3b0ba37173d0e117f",
"key_package": "0001000500010001209601e579981bc1ac27e04794c8b8bce2a8092a126ecf624f046bd0b3b939a7182098c2b26f287a1b852da6119e7fb39ab72c7e8894ee098f1886a8137984dc4f0c20db0e8d8174cc493194b38970c05553082fff59a15acb02a3b0ba37173d0e117f000103626f62020001260001000200030004000500060007f007f008f009f00af00bf00cf00df00ef00ff010f011f01200045a5a6a6a08000100023a3a5a5a010000000069e62ab20000000069fb8902004040650e7cac73d4ec0aa5ffbc2944b89fa181ec704fa5035180a07b3e2ae0089391e5b34dd9a5a91fdd71e7cd2c2bfc681a726387f85bb2f0b9e8a9aeb7cdce2f0a004040798cd7147427648b8070256708f5f87ebd9a578f29a06be5088458d37f466a7761a019636616ca1d454bd9c6a18401f8638cd0cd577c372ff813473656fef002",
"key_package_raw": "00010001209601e579981bc1ac27e04794c8b8bce2a8092a126ecf624f046bd0b3b939a7182098c2b26f287a1b852da6119e7fb39ab72c7e8894ee098f1886a8137984dc4f0c20db0e8d8174cc493194b38970c05553082fff59a15acb02a3b0ba37173d0e117f000103626f62020001260001000200030004000500060007f007f008f009f00af00bf00cf00df00ef00ff010f011f01200045a5a6a6a08000100023a3a5a5a010000000069e62ab20000000069fb8902004040650e7cac73d4ec0aa5ffbc2944b89fa181ec704fa5035180a07b3e2ae0089391e5b34dd9a5a91fdd71e7cd2c2bfc681a726387f85bb2f0b9e8a9aeb7cdce2f0a004040798cd7147427648b8070256708f5f87ebd9a578f29a06be5088458d37f466a7761a019636616ca1d454bd9c6a18401f8638cd0cd577c372ff813473656fef002"
},
"committer": {
"signer_pub": "0ebcf3e2118b902d34c3e5be8c3512eeac8976c2cd5ac51286c69cb5034a6296"
},
"welcome": "00010003000140762019089f7c0b8ee7d2f0e3423b5148c8bcbe345568d2e74c5fa9c36923091f89592087b4300936365d72c3d60890fbce56f087d6f533d9c16d1c3686ad5c552a0e2c330bc05727d271e6f92c57ca224c414ee5a85bffe2ffa8509d71630121a8e1995de446f2680c13fcdc86852eb66e0a366501ef1742a0bddbebe94583724f53ddf6140dc701432a9902b9e62a24afab8ba38515d87726bedbe9dc6c5a840aa1e305a86d57b448ff95f99f69a3f05cf18859e0eb5fa9249ccdb45205885676e903924131929f55634901f94d26516f0ff5f983ccc51ef04d92a649cf3d3f0607d00d8425a742b66dfe16c69ce3cd20f5169129ff3b4ae285e23fd9ef91be9d1175651a545e0fd05eea4244d8788e0606dec9f960fec19eabc8ba534fcb18d7c8abc943d28fa35be9df6f0b370b20da0917f49c08f2ed972cac7cbd4640651b12a64342d6d3e03be30c02f592db3336de6713449bc9f516ec5d80ee4b7076158a24304661f8fd3fce73b2caa5eb68d21570112d0750a25e9b6eaeb095e829daf97ee87e139066987bf13cf2149366eb0174f4518a6c644e3e35e0f06d2834885bff12a098edfc33f7a7a79050c03efbc31e927efcdfc8f24447f6800093345d37012db1212c116c6c413e3536908391a245ca11ae24517a1531c8940d253f19e640fc0aa9254e51d6a0c32fbb065968d62af7f3445f06a5bcb4046733c3552b268d067ba8a5de98886483de71aa3675e783d453b11a296f1fd9b69497189f202415465f5e6fd7e0e20c489d2ed481247fd2f09651e27df3f9ca63b51e650bf0658b9a26d587e71081a39689b9045c25669f535116feeb56b5967183c30f98f7bce3d52d3c9d9a5ba3168eefeeddff17988b229aaf433b5c0cff83d563e80e0310a3a2cb74088f0d2db326e9fae25d3a2dd8cf167055047f606bffde737d483d85a4d7d3440f28366db40cab26ffc600f47e595a5b6f969709fa64b01efd11a4f7156770a2ac3d44443f893ab82bd3f9c43357b691ec8e5f84371a39ac5e88db84a39e95978706eb4507af4d486a45f78e891f61ffbe7a24618a1069b25958a0816f9a050ef9b9c8493ae62de3256387b8e91523a3d24ecb",
"exporter": {
"label": "marmot",
"context": "67726f75702d6576656e74",
"length": 32,
"secret": "9dff53f9f49764ead0e755e5a3cb87fa31b495624549aa48497b63cb694ee3d9"
},
"app_messages_alice_to_bob": [
{
"plaintext": "48656c6c6f2066726f6d2074732d6d6c73",
"private_message": "0001000220f4afedf3c9cb1ba48b004e452590633d5a085bb78dba93458beee19a2790372d000000000000000101001c5989f365271a8186bacbd139dd16d1a2f2e6e21cd27fbc4c780952d04110229cd2896693b9dd7330617226c4858e245f2b06d502ca02828f3095da15fb82694b418675724f2a410ddfe5c2d791301971060b948da5d2e4121c08ef7f2d8a7ce6e803263c61a792c9ba23d8441526d7e459076e75d775f819b454b4d8bb7d5e83f5e2a1fd268c79ea31b2df425516b302ada940fa9559f67bd8e502333fbcfda69f7a98aec69bf0ebb4bfe5396fa87cbad84b53015a5c89941994e2ee0d8c5082104b97897362828da29f82142eef7480ee1e7c47ec2f093f4ab2549524575d03a3bc6adb2177f0356bde93f64b703c8b28247cac232d570af55d882713fd97e349de2a25b537fad0d1a2e200731c6a57733e53fbc5143101340230e792e58a26c33b8669c086c2271974a5e928d1"
},
{
"plaintext": "5365636f6e64206d65737361676520696e207468652073616d652065706f63682e",
"private_message": "0001000220f4afedf3c9cb1ba48b004e452590633d5a085bb78dba93458beee19a2790372d000000000000000101001c78477b49bf567b52f497a25d2b629bf048f9fda97ec1f2f7999446e34110dc70409d2db60bf82de2cb1656b2e8ecc859b9480014656a34f033e49f7c3ad57ea9f433db27df82f4fbeb800841bbd9b64ed5a3a10021113a716d1619a1735425adc938ef3bcd5190e637b69aac1b42c530e8a92307032c834b2f18790d6d4340f889749d93b2d52dab0177dcc2b7074394c484c64cbf26f1728fde8b2d8f0b94b80b78c1ad091eec11215219ca25ff2a87fac39f5b4de16e2f299943efba75f22e9c571fb7cd2ae6bcc7f7bde2031c82ca5ee5c0f211663d19dad914436751229b1bf8f294edddfe9d7167e2140d0fdd6958752d71c52eaf21b56e7b72fa7de43e7d8d624703692e3421287e3d981c9965bd6dabc197bd281aa0398499c61b5abbf6bba91fcd1123580671a210a52b"
},
{
"plaintext": "556e69636f646520776f726b7320746f6f3a20e2989520e29da4",
"private_message": "0001000220f4afedf3c9cb1ba48b004e452590633d5a085bb78dba93458beee19a2790372d000000000000000101001cc1d2206319ce378d2a33cc91db3c4af17921d40eee0be8495124fe7741105f9b2aaae492f4d5687b2e9d8251e0d95e1cec70ede8357bbe65d8cb1f31fda7e25cd780f61c344d4b30ca46acffa9cc71b191804d186d186191b73dc398d9789321b2cc9eb1b9f1b3bc3cee3429848d18d72dd224f759d01a168fee704f8ff1b368fe7ae1a4ae5ffad60cd5e11f8d024c58595c706090ef143cf391f48b023f1e316057ead6599ac8cf8dcf59bf1824cbd4e0375e0e8546b2db6ced9a95b323c20ebad21a6732b310083b88238c4ad2f8614c55841f914d74522d5f7ea27e701ca4130c9ddd7a4b142d694e609efffdd3fc791f893485af029a3f73b2f34585778f5a66e1572369715fa405fee573d3dad2373741849d9918394838eb83a0bd17e0165ce2b9a5660c3f4f3ac1d8edbf"
}
]
}
@@ -69,8 +69,9 @@ class MarmotMipBehaviorTest {
fun create_installsRequiredCapabilitiesExtension() {
val alice = MlsGroup.create(aliceId.hexToByteArray())
val reqCaps = alice.extensions.find { it.extensionType == 0x0002 }
assertNotNull(reqCaps, "create() must install required_capabilities extension (0x0002)")
// RFC 9420 §13.3: required_capabilities is extension type 0x0003.
val reqCaps = alice.extensions.find { it.extensionType == 0x0003 }
assertNotNull(reqCaps, "create() must install required_capabilities extension (0x0003)")
val reader = TlsReader(reqCaps.extensionData)
val extsBytes = reader.readOpaqueVarInt()
@@ -0,0 +1,214 @@
/*
* 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
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.marmot.mls.messages.Welcome
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import kotlin.test.Test
/**
* Reverse-interop harness: emits a Welcome + PrivateMessages authored by
* Amethyst, so a foreign MLS backend (MDK / marmot-ts) can try to join
* and decrypt. Gated on environment variables so it only runs when you
* are actually driving the cross-impl pipeline:
*
* JOINER_HANDOFF_JSON=/tmp/joiner-handoff.json \
* AMETHYST_FIXTURE_JSON=/tmp/amethyst-fixture.json \
* ./gradlew :quartz:jvmTest \
* --tests com.vitorpamplona.quartz.marmot.mls.AmethystAuthoredVectorGen
*
* The handoff file comes from the foreign backend's
* `emit-joiner-kp` helper (it holds Bob's public KP bytes and Bob's
* private keys in the foreign backend's format). Amethyst reads only
* the public `key_package_raw` here and emits the Welcome + three
* application messages to `AMETHYST_FIXTURE_JSON`. The foreign
* verifier then consumes the fixture alongside its copy of Bob's
* private keys to prove the round-trip closes.
*/
class AmethystAuthoredVectorGen {
@Serializable
private data class HandoffJson(
@SerialName("cipher_suite") val cipherSuite: Int,
val joiner: HandoffJoiner,
)
@Serializable
private data class HandoffJoiner(
@SerialName("key_package_raw") val keyPackageRaw: String,
@SerialName("init_priv") val initPriv: String = "",
)
@Test
fun emitAmethystFixture() {
val handoffPath = System.getenv("JOINER_HANDOFF_JSON") ?: return
val fixturePath =
System.getenv("AMETHYST_FIXTURE_JSON")
?: error("AMETHYST_FIXTURE_JSON env var must be set when JOINER_HANDOFF_JSON is set")
val lenientJson = Json { ignoreUnknownKeys = true }
val handoff =
lenientJson.decodeFromString<HandoffJson>(File(handoffPath).readText())
check(handoff.cipherSuite == 1) {
"only cipher_suite 1 is supported; got ${handoff.cipherSuite}"
}
// Drop the raw KeyPackage bytes straight into Amethyst. We use the
// inner (un-wrapped) form here so this matches MlsGroup.addMember's
// expectations — that entry point reads an MlsKeyPackage, not an
// MlsMessage envelope.
val bobKpBytes = handoff.joiner.keyPackageRaw.hexToByteArray()
val bobKp = MlsKeyPackage.decodeTls(TlsReader(bobKpBytes))
check(bobKp.verifySignature()) { "joiner KeyPackage signature did not verify" }
val alice = MlsGroup.create("alice".encodeToByteArray())
val addResult = alice.addMember(bobKpBytes)
val welcomeBytes =
requireNotNull(addResult.welcomeBytes) { "addMember returned no Welcome" }
// Also emit the GroupInfo plaintext as a diagnostic so foreign
// verifiers can inspect our on-wire shape byte-for-byte.
val groupInfoBytes =
run {
val mlsMsg = MlsMessage.decodeTls(TlsReader(welcomeBytes))
val welcome = Welcome.decodeTls(TlsReader(mlsMsg.payload))
val myRef = bobKp.reference()
val mySecrets =
welcome.secrets.find { it.newMember.contentEquals(myRef) }
?: error("no secrets for joiner in our own Welcome")
val initPriv = handoff.joiner.initPriv.hexToByteArray()
val gsBytes =
MlsCryptoProvider.decryptWithLabel(
initPriv,
"Welcome",
welcome.encryptedGroupInfo,
mySecrets.encryptedGroupSecrets.kemOutput,
mySecrets.encryptedGroupSecrets.ciphertext,
)
val joinerSecret = TlsReader(gsBytes).readOpaqueVarInt()
val pskSecret = ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
val memberSecret = MlsCryptoProvider.hkdfExtract(joinerSecret, pskSecret)
val welcomeSecret = MlsCryptoProvider.deriveSecret(memberSecret, "welcome")
val welcomeKey =
MlsCryptoProvider.expandWithLabel(
welcomeSecret,
"key",
ByteArray(0),
MlsCryptoProvider.AEAD_KEY_LENGTH,
)
val welcomeNonce =
MlsCryptoProvider.expandWithLabel(
welcomeSecret,
"nonce",
ByteArray(0),
MlsCryptoProvider.AEAD_NONCE_LENGTH,
)
MlsCryptoProvider.aeadDecrypt(
welcomeKey,
welcomeNonce,
ByteArray(0),
welcome.encryptedGroupInfo,
)
}
val plaintexts =
listOf(
"Hello from Amethyst",
"Second message from Amethyst at epoch 1.",
"Unicode works too: ☕ ❤",
)
val appMessages =
plaintexts.map { pt ->
val ptBytes = pt.encodeToByteArray()
val ct = alice.encrypt(ptBytes)
AppMessageEntry(ptBytes.toHexKey(), ct.toHexKey())
}
// Build the joiner-side KeyPackageBundle by re-importing the bytes so
// that we can drive Amethyst's own processWelcome end-to-end and
// record a post-join MLS-Exporter KAT. The foreign verifier doesn't
// need the exporter — but including it keeps the fixture's shape
// identical to the other cross-impl vectors.
val exporterContext = "group-event".encodeToByteArray()
val fixture =
AmethystFixture(
cipherSuite = 1,
description = "Amethyst-authored Welcome and PrivateMessages for reverse interop.",
welcome = welcomeBytes.toHexKey(),
groupInfoPlaintext = groupInfoBytes.toHexKey(),
appMessagesAliceToBob = appMessages,
exporter =
ExporterJson(
label = "marmot",
context = exporterContext.toHexKey(),
length = 32,
// The receiver computes its own exporter after
// join — foreign verifiers may compare theirs
// against ours if they want a cross-check.
secret = "",
),
)
File(fixturePath).writeText(
JsonMapper.jsonInstance.encodeToString(
AmethystFixture.serializer(),
fixture,
),
)
}
@Serializable
private data class AmethystFixture(
@SerialName("cipher_suite") val cipherSuite: Int,
val description: String,
val welcome: String,
@SerialName("group_info_plaintext")
val groupInfoPlaintext: String,
@SerialName("app_messages_alice_to_bob")
val appMessagesAliceToBob: List<AppMessageEntry>,
val exporter: ExporterJson,
)
@Serializable
private data class AppMessageEntry(
val plaintext: String,
@SerialName("private_message") val privateMessage: String,
)
@Serializable
private data class ExporterJson(
val label: String,
val context: String,
val length: Int,
val secret: String,
)
}
@@ -0,0 +1,205 @@
/*
* 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
import com.vitorpamplona.quartz.TestResourceLoader
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
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.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Interop test against a Welcome authored by MDK (the Rust MLS library
* that whitenoise uses). MDK wraps openmls 0.8, so a Welcome produced via
* openmls directly is indistinguishable from one MDK produces at the MLS
* layer. Vector is regenerated by `quartz/tools/mdk-vector-gen`.
*
* This locks in every cross-implementation assumption in the Marmot stack:
* - KeyPackage TLS encoding matches openmls.
* - HPKE open on the Welcome's encrypted_group_secrets works (the
* `eae_prk` label bug lived here; so did the `encrypted_group_info`
* context bug).
* - Welcome key schedule and AEAD decrypt of encrypted_group_info.
* - Ratchet tree decoding and leaf lookup by signature key.
* - GroupInfoTBS Ed25519 signature verification.
* - Post-join MLS-Exporter (label="marmot", context="group-event")
* KAT the same exporter Amethyst uses to derive the outer ChaCha20
* key for group events.
*/
class MdkWelcomeInteropTest {
@Serializable
private data class MdkVector(
@SerialName("cipher_suite") val cipherSuite: Int,
val description: String,
val joiner: Joiner,
val committer: Committer,
val welcome: String,
val exporter: Exporter,
@SerialName("app_messages_alice_to_bob")
val appMessages: List<AppMessage> = emptyList(),
)
@Serializable
private data class AppMessage(
val plaintext: String,
@SerialName("private_message") val privateMessage: String,
)
@Serializable
private data class Joiner(
@SerialName("init_priv") val initPriv: String,
@SerialName("encryption_priv") val encryptionPriv: String,
@SerialName("signature_priv") val signaturePriv: String,
@SerialName("signature_pub") val signaturePub: String,
@SerialName("key_package") val keyPackage: String,
@SerialName("key_package_raw") val keyPackageRaw: String,
)
@Serializable
private data class Committer(
@SerialName("signer_pub") val signerPub: String,
)
@Serializable
private data class Exporter(
val label: String,
val context: String,
val length: Int,
val secret: String,
)
private val vector: MdkVector =
JsonMapper.jsonInstance.decodeFromString<MdkVector>(
TestResourceLoader().loadString("mls/mdk-welcome.json"),
)
@Test
fun testMdkKeyPackageRoundTripAndSignature() {
assertEquals(1, vector.cipherSuite)
val kpMsg = MlsMessage.decodeTls(TlsReader(vector.joiner.keyPackage.hexToByteArray()))
assertEquals(WireFormat.KEY_PACKAGE, kpMsg.wireFormat)
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
assertContentEquals(
vector.joiner.keyPackageRaw.hexToByteArray(),
kp.toTlsBytes(),
"Inner KeyPackage bytes should round-trip",
)
assertTrue(kp.verifySignature(), "MDK-authored KeyPackage signature must verify")
}
@Test
fun testProcessMdkWelcomeAndDeriveExporterSecret() {
// Reconstruct the joiner's KeyPackageBundle from vector private keys.
val kpMsg = MlsMessage.decodeTls(TlsReader(vector.joiner.keyPackage.hexToByteArray()))
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
// Amethyst's Ed25519 expects seed || pub (64 bytes); the generator
// emits the 32-byte seed and pub separately.
val sigPriv =
vector.joiner.signaturePriv.hexToByteArray() +
vector.joiner.signaturePub.hexToByteArray()
val bundle =
KeyPackageBundle(
keyPackage = kp,
initPrivateKey = vector.joiner.initPriv.hexToByteArray(),
encryptionPrivateKey = vector.joiner.encryptionPriv.hexToByteArray(),
signaturePrivateKey = sigPriv,
)
val group = MlsGroup.processWelcome(vector.welcome.hexToByteArray(), bundle)
// Verify the exporter secret MDK computed post-join matches ours.
val ourExporter =
group.exporterSecret(
label = vector.exporter.label,
context = vector.exporter.context.hexToByteArray(),
length = vector.exporter.length,
)
assertEquals(
vector.exporter.secret,
ourExporter.toHexKey(),
"MLS-Exporter disagreement post-join against MDK/openmls",
)
assertNotNull(group, "processWelcome returned null")
}
/**
* 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
* opaque signature<V>; // FramedContentTBS signature
* opaque padding[N]; // zero padding
* } PrivateMessageContent;
*
* layout, verify the Ed25519 signature over FramedContentTBS using the
* sender's LeafNode.signature_key, and return only `application_data`.
*/
@Test
fun testBobDecryptsMdkApplicationMessagesFromAlice() {
assertTrue(
vector.appMessages.isNotEmpty(),
"mdk-welcome.json should ship at least one app_messages_alice_to_bob entry",
)
val kpMsg = MlsMessage.decodeTls(TlsReader(vector.joiner.keyPackage.hexToByteArray()))
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
val sigPriv =
vector.joiner.signaturePriv.hexToByteArray() +
vector.joiner.signaturePub.hexToByteArray()
val bundle =
KeyPackageBundle(
keyPackage = kp,
initPrivateKey = vector.joiner.initPriv.hexToByteArray(),
encryptionPrivateKey = vector.joiner.encryptionPriv.hexToByteArray(),
signaturePrivateKey = sigPriv,
)
val bob = MlsGroup.processWelcome(vector.welcome.hexToByteArray(), bundle)
for ((idx, msg) in vector.appMessages.withIndex()) {
val decrypted = bob.decrypt(msg.privateMessage.hexToByteArray())
assertContentEquals(
msg.plaintext.hexToByteArray(),
decrypted.content,
"Application message $idx plaintext mismatch",
)
}
}
}
@@ -146,12 +146,12 @@ class MlsConformanceTest {
assertEquals(0, decoded.signer, "Signer should be leaf 0 (group creator)")
assertTrue(decoded.signature.isNotEmpty(), "GroupInfo must be signed")
// Must contain ratchet_tree extension (type 0x0001)
val ratchetTreeExt = decoded.extensions.find { it.extensionType == 0x0001 }
// Must contain ratchet_tree extension (RFC 9420 §13.3: type 0x0002)
val ratchetTreeExt = decoded.extensions.find { it.extensionType == 0x0002 }
assertTrue(ratchetTreeExt != null, "GroupInfo must contain ratchet_tree extension")
// Must contain external_pub extension (type 0x0003)
val externalPubExt = decoded.extensions.find { it.extensionType == 0x0003 }
// Must contain external_pub extension (RFC 9420 §13.3: type 0x0004)
val externalPubExt = decoded.extensions.find { it.extensionType == 0x0004 }
assertTrue(externalPubExt != null, "GroupInfo must contain external_pub extension")
assertEquals(32, externalPubExt.extensionData.size, "external_pub must be 32 bytes (X25519)")
}
@@ -394,7 +394,8 @@ class MlsConformanceTest {
// Verify it's derived from external_secret via DeriveKeyPair
// (This is an internal consistency check)
val groupInfo = group.groupInfo()
val extPubFromGI = groupInfo.extensions.find { it.extensionType == 0x0003 }
// RFC 9420 §13.3: external_pub is extension type 0x0004.
val extPubFromGI = groupInfo.extensions.find { it.extensionType == 0x0004 }
assertContentEquals(externalPub, extPubFromGI!!.extensionData)
}
@@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.marmot.mls
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
@@ -225,12 +224,6 @@ class MlsGroupEdgeCaseTest {
// 6. Multiple epochs of encrypt/decrypt
// -----------------------------------------------------------------------
// BUG: same empty-commit (no proposals, pure UpdatePath) divergence as
// MlsGroupLifecycleTest.testEmptyCommit_AdvancesEpoch. The
// SecretTree.getNodeSecret non-full-tree fix doesn't address this — after
// 5 empty commits Alice and Bob's ratchet secrets diverge and AEAD
// decryption fails with "Tag mismatch". Tracked separately.
@Ignore
@Test
fun testMultipleEpochTransitions_EncryptDecryptStillWorks() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.marmot.mls
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
@@ -466,12 +465,6 @@ class MlsGroupLifecycleTest {
// 12. Empty commit (no proposals, just UpdatePath for forward secrecy)
// -----------------------------------------------------------------------
// BUG: empty-commit (no proposals, pure UpdatePath) diverges Alice's and
// Bob's exporter secrets after processCommit. Unrelated to the SecretTree
// non-full-tree fix — the other @Ignored "processCommit diverges" tests in
// this file and MlsGroupEdgeCaseTest now pass, but this one still fails.
// Tracked separately.
@Ignore
@Test
fun testEmptyCommit_AdvancesEpoch() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -0,0 +1,180 @@
/*
* 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
import com.vitorpamplona.quartz.TestResourceLoader
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
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.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Interop test against a Welcome authored by ts-mls the TypeScript MLS
* implementation that powers marmot-ts (the Marmot client for browsers /
* Node / Bun / Deno). ts-mls is a completely independent codebase from
* OpenMLS (MDK's backend), so a clean pass here means Amethyst agrees
* byte-for-byte with two MLS implementations on every crypto surface
* Marmot touches.
*
* Vector is regenerated by `quartz/tools/tsmls-vector-gen`.
*/
class TsMlsWelcomeInteropTest {
@Serializable
private data class TsMlsVector(
@SerialName("cipher_suite") val cipherSuite: Int,
val description: String,
val joiner: Joiner,
val committer: Committer,
val welcome: String,
val exporter: Exporter,
@SerialName("app_messages_alice_to_bob")
val appMessages: List<AppMessage> = emptyList(),
)
@Serializable
private data class AppMessage(
val plaintext: String,
@SerialName("private_message") val privateMessage: String,
)
@Serializable
private data class Joiner(
@SerialName("init_priv") val initPriv: String,
@SerialName("encryption_priv") val encryptionPriv: String,
@SerialName("signature_priv") val signaturePriv: String,
@SerialName("signature_pub") val signaturePub: String,
@SerialName("key_package") val keyPackage: String,
@SerialName("key_package_raw") val keyPackageRaw: String,
)
@Serializable
private data class Committer(
@SerialName("signer_pub") val signerPub: String,
)
@Serializable
private data class Exporter(
val label: String,
val context: String,
val length: Int,
val secret: String,
)
private val vector: TsMlsVector =
JsonMapper.jsonInstance.decodeFromString<TsMlsVector>(
TestResourceLoader().loadString("mls/tsmls-welcome.json"),
)
@Test
fun testTsMlsKeyPackageRoundTripAndSignature() {
assertEquals(1, vector.cipherSuite)
val kpMsg = MlsMessage.decodeTls(TlsReader(vector.joiner.keyPackage.hexToByteArray()))
assertEquals(WireFormat.KEY_PACKAGE, kpMsg.wireFormat)
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
assertContentEquals(
vector.joiner.keyPackageRaw.hexToByteArray(),
kp.toTlsBytes(),
"Inner KeyPackage bytes should round-trip",
)
assertTrue(kp.verifySignature(), "ts-mls-authored KeyPackage signature must verify")
}
@Test
fun testProcessTsMlsWelcomeAndDeriveExporterSecret() {
val kpMsg = MlsMessage.decodeTls(TlsReader(vector.joiner.keyPackage.hexToByteArray()))
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
// Amethyst's Ed25519 expects seed || pub (64 bytes); the generator
// emits the 32-byte seed and pub separately.
val sigPriv =
vector.joiner.signaturePriv.hexToByteArray() +
vector.joiner.signaturePub.hexToByteArray()
val bundle =
KeyPackageBundle(
keyPackage = kp,
initPrivateKey = vector.joiner.initPriv.hexToByteArray(),
encryptionPrivateKey = vector.joiner.encryptionPriv.hexToByteArray(),
signaturePrivateKey = sigPriv,
)
val group = MlsGroup.processWelcome(vector.welcome.hexToByteArray(), bundle)
val ourExporter =
group.exporterSecret(
label = vector.exporter.label,
context = vector.exporter.context.hexToByteArray(),
length = vector.exporter.length,
)
assertEquals(
vector.exporter.secret,
ourExporter.toHexKey(),
"MLS-Exporter disagreement post-join against ts-mls",
)
assertNotNull(group, "processWelcome returned null")
}
@Test
fun testBobDecryptsTsMlsApplicationMessagesFromAlice() {
assertTrue(
vector.appMessages.isNotEmpty(),
"tsmls-welcome.json should ship at least one app_messages_alice_to_bob entry",
)
val kpMsg = MlsMessage.decodeTls(TlsReader(vector.joiner.keyPackage.hexToByteArray()))
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
val sigPriv =
vector.joiner.signaturePriv.hexToByteArray() +
vector.joiner.signaturePub.hexToByteArray()
val bundle =
KeyPackageBundle(
keyPackage = kp,
initPrivateKey = vector.joiner.initPriv.hexToByteArray(),
encryptionPrivateKey = vector.joiner.encryptionPriv.hexToByteArray(),
signaturePrivateKey = sigPriv,
)
val bob = MlsGroup.processWelcome(vector.welcome.hexToByteArray(), bundle)
for ((idx, msg) in vector.appMessages.withIndex()) {
val decrypted = bob.decrypt(msg.privateMessage.hexToByteArray())
assertContentEquals(
msg.plaintext.hexToByteArray(),
decrypted.content,
"ts-mls application message $idx plaintext mismatch",
)
}
}
}