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",
)
}
}
}
+2
View File
@@ -0,0 +1,2 @@
/target
/Cargo.lock
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "mdk-vector-gen"
version = "0.1.0"
edition = "2021"
[dependencies]
openmls = { version = "0.8.1", features = ["test-utils"] }
openmls_rust_crypto = "0.5.1"
openmls_basic_credential = { version = "0.5", features = ["test-utils"] }
openmls_memory_storage = { version = "0.5", features = ["persistence"] }
openmls_traits = "0.5"
tls_codec = "0.4"
hex = "0.4"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
base64 = "0.22"
env_logger = "0.11"
[[bin]]
name = "mdk-vector-gen"
path = "src/main.rs"
[[bin]]
name = "emit-joiner-kp"
path = "src/emit_joiner_kp.rs"
[[bin]]
name = "verify-amethyst"
path = "src/verify_amethyst.rs"
+36
View File
@@ -0,0 +1,36 @@
# mdk-vector-gen
Rust helper that emits MLS interop test vectors from the same openmls 0.8
backend MDK/whitenoise uses. Produces `quartz/src/commonTest/resources/mls/mdk-welcome.json`,
which [`MdkWelcomeInteropTest`] consumes to prove Amethyst can parse and
decrypt a Welcome + application messages authored by the Rust side.
## What the vector contains
- `joiner.init_priv` / `encryption_priv` / `signature_priv` / `signature_pub`
all the private key material the joiner needs to drive
`MlsGroup.processWelcome`.
- `joiner.key_package` (MlsMessage-wrapped) and `key_package_raw`.
- `welcome` (MlsMessage-wrapped) — Alice's Welcome for Bob.
- `committer.signer_pub` — Alice's Ed25519 signature public key.
- `exporter.{label,context,length,secret}` — `MLS-Exporter("marmot",
"group-event", 32)` derived from Bob's post-join state. This is the
exporter Marmot uses to derive the outer ChaCha20 key for kind:445
events, so a match here means Amethyst's whole post-join key schedule
agrees with openmls byte-for-byte.
- `app_messages_alice_to_bob[]` — Alice-sent PrivateMessage bytes plus
the expected plaintext. (Amethyst decrypt currently skips
`PrivateMessageContent` framing; the matching test is `@Ignore`d
until that is fixed.)
## Regenerating
```
cd quartz/tools/mdk-vector-gen
cargo run --release > ../../src/commonTest/resources/mls/mdk-welcome.json
```
The generator uses fresh randomness each run, so the committed vector
changes on regeneration — that is fine because the Kotlin test only
asserts round-trip correctness against whatever is in the JSON. Commit
the regenerated file if you change the generator.
@@ -0,0 +1,96 @@
// Produce an MDK/OpenMLS-authored joiner KeyPackage for the reverse-interop
// test (Amethyst → MDK). Emits TWO artifacts:
// - a binary storage snapshot at $1 (openmls_memory_storage save_to_file
// format) so verify_amethyst.rs can restore Bob's provider exactly as
// it was when the KP was generated;
// - JSON on stdout with the public KP bytes plus the private keys
// (hex-encoded) so Amethyst can drive its addMember and the reader
// can sanity-check shapes:
//
// {
// "cipher_suite": 1,
// "joiner": {
// "key_package_raw": hex,
// "init_priv": hex(32 B),
// "encryption_priv": hex(32 B),
// "signature_priv": hex(32 B, raw Ed25519 seed),
// "signature_pub": hex(32 B)
// }
// }
use std::env;
use std::fs::File;
use std::process;
use openmls::prelude::*;
use openmls_basic_credential::SignatureKeyPair;
use openmls_rust_crypto::OpenMlsRustCrypto;
use openmls_traits::OpenMlsProvider;
use tls_codec::Serialize;
const CS: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 2 {
eprintln!("usage: emit-joiner-kp <storage-snapshot-path>");
process::exit(2);
}
let snapshot_path = &args[1];
let provider = OpenMlsRustCrypto::default();
let cred = BasicCredential::new(b"bob".to_vec());
let sig = SignatureKeyPair::new(CS.signature_algorithm()).unwrap();
sig.store(provider.storage()).unwrap();
let cwk = CredentialWithKey {
credential: cred.into(),
signature_key: sig.public().into(),
};
// Advertise the Marmot extensions a real MDK KeyPackage would carry,
// so Amethyst's required_capabilities (which lists marmot_group_data
// 0xF2EE and the self_remove proposal 0x000A) is satisfied when our
// KP is added to an Amethyst group.
let capabilities = Capabilities::new(
None,
Some(&[CS]),
Some(&[
ExtensionType::from(0xF2EE), // marmot_group_data
ExtensionType::ApplicationId,
ExtensionType::LastResort,
]),
Some(&[openmls::prelude::ProposalType::SelfRemove]),
None,
);
let bundle = KeyPackage::builder()
.leaf_node_capabilities(capabilities)
.mark_as_last_resort()
.build(CS, &provider, &sig, cwk)
.unwrap();
let kp = bundle.key_package().clone();
let kp_bytes = kp.tls_serialize_detached().unwrap();
let init_priv: Vec<u8> = (**bundle.init_private_key()).to_vec();
let enc_priv: Vec<u8> = (**bundle.encryption_private_key()).to_vec();
// Persist the entire provider storage — validate_amethyst restores it
// byte-for-byte so the KeyPackage + bundle + signature key can be used
// to process a Welcome.
let file = File::create(snapshot_path).expect("open snapshot file");
provider
.storage()
.save_to_file(&file)
.expect("save storage snapshot");
let out = serde_json::json!({
"cipher_suite": 1,
"joiner": {
"key_package_raw": hex::encode(&kp_bytes),
"init_priv": hex::encode(&init_priv),
"encryption_priv": hex::encode(&enc_priv),
"signature_priv": hex::encode(sig.private()),
"signature_pub": hex::encode(sig.public()),
}
});
println!("{}", serde_json::to_string_pretty(&out).unwrap());
}
+144
View File
@@ -0,0 +1,144 @@
// Generate MDK/OpenMLS interop test vectors for the Amethyst Marmot module.
//
// Emits a JSON document containing, for cipher suite
// MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 (0x0001):
// - joiner.init_priv (32 bytes hex) — HPKE KEM private for Welcome unwrap
// - joiner.signature_priv (32 bytes hex seed || 32 bytes pub) — Ed25519 private
// - joiner.key_package (hex, MlsMessage-wrapped) — the KeyPackage on the wire
// - committer.signer_pub (32 bytes hex) — for GroupInfoTBS verification
// - welcome (hex, MlsMessage-wrapped) — what the sender sent
// - exporter.{label,context,length,secret} — MLS-Exporter KAT derived post-join
//
// The joiner pretends to be offline: we build their KeyPackage, hand the
// public half to the committer, then keep the three private keys so the
// vector is fully-self-decryptable.
use openmls::prelude::*;
use openmls_basic_credential::SignatureKeyPair;
use openmls_rust_crypto::OpenMlsRustCrypto;
use openmls_traits::OpenMlsProvider;
use tls_codec::{Deserialize, Serialize};
const CS: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
fn new_signer(identity: &[u8]) -> (SignatureKeyPair, CredentialWithKey) {
let cred = BasicCredential::new(identity.to_vec());
let sig = SignatureKeyPair::new(CS.signature_algorithm()).unwrap();
let cwk = CredentialWithKey {
credential: cred.into(),
signature_key: sig.public().into(),
};
(sig, cwk)
}
fn main() {
let provider_a = OpenMlsRustCrypto::default();
let provider_b = OpenMlsRustCrypto::default();
let (alice_sig, alice_cwk) = new_signer(b"alice");
alice_sig.store(provider_a.storage()).unwrap();
let (bob_sig, bob_cwk) = new_signer(b"bob");
bob_sig.store(provider_b.storage()).unwrap();
// Bob builds a KeyPackage with openmls defaults + LastResort (marks it as
// a long-lived KP per MDK behaviour).
let bob_kp_bundle = KeyPackage::builder()
.mark_as_last_resort()
.build(CS, &provider_b, &bob_sig, bob_cwk.clone())
.unwrap();
let bob_kp = bob_kp_bundle.key_package().clone();
let bob_kp_bytes_raw = bob_kp.tls_serialize_detached().unwrap();
// Wrap in MlsMessage (the shape Amethyst decodes first)
let bob_kp_msg: MlsMessageOut = MlsMessageOut::from(bob_kp.clone());
let bob_kp_msg_bytes = bob_kp_msg.tls_serialize_detached().unwrap();
// Pull Bob's HPKE init private key out of storage via the KeyPackageBundle.
let bob_init_priv: Vec<u8> = (**bob_kp_bundle.init_private_key()).to_vec();
let bob_enc_priv: Vec<u8> = (**bob_kp_bundle.encryption_private_key()).to_vec();
// Alice creates the group with Bob as an initial member.
let group_cfg = MlsGroupCreateConfig::builder()
.ciphersuite(CS)
.wire_format_policy(openmls::group::MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY)
.use_ratchet_tree_extension(true)
.build();
let mut alice_group = MlsGroup::new(
&provider_a,
&alice_sig,
&group_cfg,
alice_cwk.clone(),
)
.unwrap();
let (_commit_out, welcome_out, _group_info) = alice_group
.add_members(&provider_a, &alice_sig, &[bob_kp.clone()])
.unwrap();
alice_group.merge_pending_commit(&provider_a).unwrap();
// Commit is not used in this vector — we only need the Welcome.
let welcome_bytes = welcome_out.tls_serialize_detached().unwrap();
// Drive Bob through processing so we can emit an MLS-Exporter KAT the
// Amethyst test can derive independently.
let welcome_in = MlsMessageIn::tls_deserialize(&mut welcome_bytes.as_slice()).unwrap();
let welcome = match welcome_in.extract() {
MlsMessageBodyIn::Welcome(w) => w,
other => panic!("expected Welcome, got {other:?}"),
};
let cfg = MlsGroupJoinConfig::builder().build();
let staged = StagedWelcome::new_from_welcome(&provider_b, &cfg, welcome, None).unwrap();
let bob_group = staged.into_group(&provider_b).unwrap();
let exporter_label = "marmot";
let exporter_context = b"group-event";
let exporter_length: usize = 32;
let exporter_secret = bob_group
.export_secret(provider_b.crypto(), exporter_label, exporter_context, exporter_length)
.unwrap();
// Alice sends three application messages to the group. Bob will replay
// them through Amethyst's `MlsGroup.decrypt` and verify the plaintexts.
let plaintexts: Vec<&[u8]> = vec![
b"Hello Bob!".as_ref(),
b"Second message in the same epoch.".as_ref(),
b"Unicode works too: \xe2\x98\x95 \xe2\x9d\xa4".as_ref(),
];
let mut app_messages = Vec::new();
for pt in &plaintexts {
let out = alice_group
.create_message(&provider_a, &alice_sig, pt)
.unwrap();
let bytes = out.tls_serialize_detached().unwrap();
app_messages.push(serde_json::json!({
"plaintext": hex::encode(pt),
"private_message": hex::encode(&bytes),
}));
}
let vector = serde_json::json!({
"cipher_suite": 1,
"description": "Alice creates a group and welcomes Bob via openmls 0.8 (MDK's MLS backend).",
"joiner": {
"init_priv": hex::encode(&bob_init_priv),
"encryption_priv": hex::encode(&bob_enc_priv),
"signature_priv": hex::encode(bob_sig.private()),
"signature_pub": hex::encode(bob_sig.public()),
"key_package": hex::encode(&bob_kp_msg_bytes),
"key_package_raw": hex::encode(&bob_kp_bytes_raw),
},
"committer": {
"signer_pub": hex::encode(alice_sig.public()),
},
"welcome": hex::encode(&welcome_bytes),
"exporter": {
"label": exporter_label,
"context": hex::encode(exporter_context),
"length": exporter_length,
"secret": hex::encode(&exporter_secret),
},
"app_messages_alice_to_bob": app_messages,
});
println!("{}", serde_json::to_string_pretty(&vector).unwrap());
}
@@ -0,0 +1,153 @@
// Reverse-interop verifier: Amethyst → MDK/OpenMLS.
//
// Takes three paths on argv:
// arg1 — path to the binary storage snapshot written by emit_joiner_kp.rs
// (restores Bob's openmls provider exactly as it was when the
// KeyPackage + bundle were generated);
// arg2 — the joiner-handoff JSON (we only read the signature_pub here,
// to look up Bob's own leaf after joining);
// arg3 — the Amethyst-authored fixture (Welcome + PrivateMessages).
//
// Prints PASS/... lines on success and exits non-zero on any failure.
use std::env;
use std::fs::{self, File};
use std::process;
use base64::Engine;
use openmls::prelude::*;
use openmls_rust_crypto::OpenMlsRustCrypto;
use openmls_traits::OpenMlsProvider;
use serde::Deserialize;
use std::collections::HashMap;
use tls_codec::Deserialize as TlsDeserialize;
#[derive(Deserialize)]
struct Handoff {
cipher_suite: u16,
joiner: HandoffJoiner,
}
#[derive(Deserialize)]
struct HandoffJoiner {
signature_pub: String,
}
#[derive(Deserialize)]
struct Fixture {
cipher_suite: u16,
welcome: String,
app_messages_alice_to_bob: Vec<AppMessage>,
}
#[derive(Deserialize)]
struct AppMessage {
plaintext: String,
private_message: String,
}
fn hex_bytes(s: &str) -> Vec<u8> {
hex::decode(s).expect("bad hex in fixture")
}
fn fail(msg: &str) -> ! {
eprintln!("FAIL: {msg}");
process::exit(1);
}
fn main() {
env_logger::init();
let args: Vec<String> = env::args().collect();
if args.len() != 4 {
eprintln!(
"usage: verify-amethyst <storage-snapshot.bin> \
<joiner-handoff.json> <amethyst-fixture.json>"
);
process::exit(2);
}
let handoff: Handoff = serde_json::from_str(&fs::read_to_string(&args[2]).unwrap()).unwrap();
let fixture: Fixture = serde_json::from_str(&fs::read_to_string(&args[3]).unwrap()).unwrap();
assert_eq!(handoff.cipher_suite, 1);
assert_eq!(fixture.cipher_suite, 1);
// Restore Bob's MemoryStorage key/value map from the snapshot. We
// replicate the on-disk format here (it's a simple base64-encoded
// HashMap) instead of going through MemoryStorage::load_from_file —
// that API wants `&mut self` on a field we can't move into the
// OpenMlsRustCrypto provider (its fields are private and there's no
// constructor that accepts a pre-populated storage).
#[derive(Deserialize)]
struct SerializableKeyStore {
values: HashMap<String, String>,
}
let snap: SerializableKeyStore =
serde_json::from_reader(File::open(&args[1]).expect("open snapshot for read"))
.expect("parse snapshot JSON");
let provider = OpenMlsRustCrypto::default();
{
let mut map = provider.storage().values.write().unwrap();
for (k, v) in snap.values {
map.insert(
base64::prelude::BASE64_STANDARD.decode(k).unwrap(),
base64::prelude::BASE64_STANDARD.decode(v).unwrap(),
);
}
}
// Parse Amethyst's Welcome.
let welcome_bytes = hex_bytes(&fixture.welcome);
let msg_in = MlsMessageIn::tls_deserialize(&mut welcome_bytes.as_slice())
.unwrap_or_else(|e| fail(&format!("welcome decode: {e:?}")));
let welcome = match msg_in.extract() {
MlsMessageBodyIn::Welcome(w) => w,
other => fail(&format!("expected Welcome, got {other:?}")),
};
let cfg = MlsGroupJoinConfig::builder().build();
let staged = StagedWelcome::new_from_welcome(&provider, &cfg, welcome, None)
.unwrap_or_else(|e| fail(&format!("StagedWelcome::new_from_welcome: {e:?}")));
let mut group = staged
.into_group(&provider)
.unwrap_or_else(|e| fail(&format!("StagedWelcome::into_group: {e:?}")));
println!(
"PASS: joined Amethyst-authored Welcome at epoch {}",
group.epoch().as_u64()
);
// Sanity-check: the signature_pub we advertised must appear in the
// ratchet tree as OUR leaf.
let expected_sig_pub = hex_bytes(&handoff.joiner.signature_pub);
let own_leaf = group.own_leaf_node().unwrap();
if own_leaf.signature_key().as_slice() != expected_sig_pub.as_slice() {
fail("own leaf signature key does not match handoff signature_pub");
}
for (idx, app) in fixture.app_messages_alice_to_bob.iter().enumerate() {
let bytes = hex_bytes(&app.private_message);
let msg = MlsMessageIn::tls_deserialize(&mut bytes.as_slice())
.unwrap_or_else(|e| fail(&format!("app msg {idx} decode: {e:?}")));
let protocol = msg
.try_into_protocol_message()
.unwrap_or_else(|e| fail(&format!("app msg {idx} not a protocol message: {e:?}")));
let processed = group
.process_message(&provider, protocol)
.unwrap_or_else(|e| fail(&format!("app msg {idx} process_message: {e:?}")));
match processed.into_content() {
ProcessedMessageContent::ApplicationMessage(app_msg) => {
let got = app_msg.into_bytes();
let want = hex_bytes(&app.plaintext);
if got != want {
fail(&format!(
"app msg {idx} plaintext mismatch\n want: {}\n got : {}",
hex::encode(&want),
hex::encode(&got),
));
}
println!("PASS: decrypted Amethyst app message {idx}");
}
other => fail(&format!("app msg {idx} unexpected content: {other:?}")),
}
}
println!("ALL PASS — openmls read every Amethyst-authored artifact.");
}
+2
View File
@@ -0,0 +1,2 @@
/node_modules
/package-lock.json
+26
View File
@@ -0,0 +1,26 @@
# tsmls-vector-gen
Node helper that emits MLS interop test vectors from `ts-mls`, the
TypeScript MLS implementation that powers
[marmot-ts](https://github.com/marmot-protocol/marmot-ts) (the Marmot
protocol client that runs in the browser / Node / Bun / Deno).
Produces `quartz/src/commonTest/resources/mls/tsmls-welcome.json`, which
`TsMlsWelcomeInteropTest` consumes to prove Amethyst can parse and
decrypt a Welcome + application messages authored by the TypeScript
side. Together with `mdk-vector-gen` (OpenMLS/Rust) the Marmot suite now
has cross-implementation KATs from two independent MLS backends.
## Regenerating
```
cd quartz/tools/tsmls-vector-gen
npm install --legacy-peer-deps
node generate.mjs > ../../src/commonTest/resources/mls/tsmls-welcome.json
```
The generator uses fresh randomness each run; commit the regenerated
JSON if you change the generator. `ts-mls` returns Ed25519 signature
private keys as PKCS#8-DER envelopes (16-byte header + 32-byte seed);
we strip the header before emitting so the vector's `signature_priv`
field is directly comparable to the one `mdk-vector-gen` produces.
@@ -0,0 +1,70 @@
// Produce a ts-mls-authored joiner KeyPackage for the reverse-interop test
// (Amethyst → ts-mls). Emits on stdout:
//
// {
// "cipher_suite": 1,
// "joiner": {
// "key_package_raw": hex, // inner KeyPackage bytes
// "init_priv": hex(32 B),
// "encryption_priv":hex(32 B),
// "signature_priv": hex(32 B, raw seed), // PKCS#8 header stripped
// "signature_pub": hex(32 B)
// }
// }
//
// Amethyst reads `key_package_raw` to drive its MlsGroup.addMember flow;
// verify-amethyst-fixture.mjs reads the private keys back to drive
// ts-mls joinGroup + processPrivateMessage against Amethyst's Welcome.
import {
defaultCapabilities,
defaultCryptoProvider,
defaultLifetime,
generateKeyPackage,
getCiphersuiteImpl,
keyPackageEncoder,
encode,
} from "ts-mls";
const hex = (bytes) =>
Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
const cs = await getCiphersuiteImpl(
"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519",
defaultCryptoProvider,
);
// Mirror MDK: advertise marmot_group_data (0xF2EE) as a supported
// extension and self_remove (0x000A) as a supported proposal so
// Amethyst's required_capabilities is satisfied when our KP is added.
const caps = defaultCapabilities();
if (!caps.extensions.includes(0xf2ee)) caps.extensions.push(0xf2ee);
if (!caps.proposals.includes(0x000a)) caps.proposals.push(0x000a);
const kp = await generateKeyPackage({
credential: { credentialType: 1, identity: new TextEncoder().encode("bob") },
capabilities: caps,
lifetime: defaultLifetime(),
cipherSuite: cs,
});
const sigPriv = kp.privatePackage.signaturePrivateKey;
const sigSeed =
sigPriv.length === 32
? sigPriv
: sigPriv.length === 64
? sigPriv.slice(0, 32)
: sigPriv.slice(sigPriv.length - 32);
const out = {
cipher_suite: 1,
joiner: {
key_package_raw: hex(encode(keyPackageEncoder, kp.publicPackage)),
init_priv: hex(kp.privatePackage.initPrivateKey),
encryption_priv: hex(kp.privatePackage.hpkePrivateKey),
signature_priv: hex(sigSeed),
signature_pub: hex(kp.publicPackage.leafNode.signaturePublicKey),
},
};
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
+190
View File
@@ -0,0 +1,190 @@
// Generate marmot-ts / ts-mls interop vectors for the Amethyst Marmot module.
//
// ts-mls is the TypeScript MLS implementation that powers marmot-ts (the
// Marmot protocol client that runs in the browser and on Node/Bun/Deno).
// This generator uses ts-mls directly at the MLS layer — the Nostr wrapping
// marmot-ts adds is orthogonal to the cipher, so an MLS-level interop match
// against ts-mls is the same interop property we'd get from running the
// marmot-ts client end-to-end.
//
// Output: a JSON document on stdout with the same shape as mdk-welcome.json,
// so the Amethyst MdkWelcomeInteropTest test harness can consume either.
import {
ciphersuites,
createApplicationMessage,
createCommit,
createGroup,
decode,
defaultCryptoProvider,
defaultLifetime,
encode,
generateKeyPackage,
getCiphersuiteImpl,
joinGroup,
keyPackageEncoder,
mlsExporter,
mlsMessageDecoder,
mlsMessageEncoder,
protocolVersions,
unsafeTestingAuthenticationService,
wireformats,
} from "ts-mls";
const hex = (bytes) =>
Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
async function main() {
const cs = await getCiphersuiteImpl(
"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519",
defaultCryptoProvider,
);
const ctx = {
cipherSuite: cs,
authService: unsafeTestingAuthenticationService,
};
// Alice + Bob KeyPackages
const aliceKp = await generateKeyPackage({
credential: { credentialType: 1 /* basic */, identity: new TextEncoder().encode("alice") },
lifetime: defaultLifetime(),
cipherSuite: cs,
});
const bobKp = await generateKeyPackage({
credential: { credentialType: 1 /* basic */, identity: new TextEncoder().encode("bob") },
lifetime: defaultLifetime(),
cipherSuite: cs,
});
// Alice creates the group
const groupId = crypto.getRandomValues(new Uint8Array(32));
const aliceState0 = await createGroup({
context: ctx,
groupId,
keyPackage: aliceKp.publicPackage,
privateKeyPackage: aliceKp.privatePackage,
extensions: [],
});
// Alice adds Bob via a commit containing an Add proposal
const { newState: aliceState1, welcome, commit } = await createCommit({
context: ctx,
state: aliceState0,
extraProposals: [{ proposalType: 1 /* add */, add: { keyPackage: bobKp.publicPackage } }],
ratchetTreeExtension: true,
});
if (!welcome) throw new Error("createCommit did not produce a Welcome");
const welcomeBytes = encode(mlsMessageEncoder, welcome);
// Bob joins from the Welcome
const welcomeRoundTrip = decode(mlsMessageDecoder, welcomeBytes);
if (!welcomeRoundTrip || welcomeRoundTrip.wireformat !== wireformats.mls_welcome) {
throw new Error("round-trip welcome not of wire_format mls_welcome");
}
const bobState1 = await joinGroup({
context: ctx,
welcome: welcomeRoundTrip.welcome,
keyPackage: bobKp.publicPackage,
privateKeys: bobKp.privatePackage,
});
// MLS-Exporter KAT: the "marmot" / "group-event" exporter used to seal
// kind:445 outer envelopes.
const exporterLabel = "marmot";
const exporterContext = new TextEncoder().encode("group-event");
const exporterLength = 32;
const exporterSecret = await mlsExporter(
bobState1.keySchedule.exporterSecret,
exporterLabel,
exporterContext,
exporterLength,
cs,
);
// Alice sends three application messages that Bob's side should decrypt.
// We ratchet aliceState forward between sends.
let aliceCursor = aliceState1;
const plaintexts = [
"Hello from ts-mls",
"Second message in the same epoch.",
"Unicode works too: ☕ ❤",
];
const appMessages = [];
for (const pt of plaintexts) {
const ptBytes = new TextEncoder().encode(pt);
const { newState, message } = await createApplicationMessage({
context: ctx,
state: aliceCursor,
message: ptBytes,
});
aliceCursor = newState;
const msgBytes = encode(mlsMessageEncoder, message);
appMessages.push({
plaintext: hex(ptBytes),
private_message: hex(msgBytes),
});
}
// Bob's exported signature public key (we stored Ed25519 seed ourselves)
// ts-mls generateKeyPackage keeps the full signing-key pair in the
// PrivateKeyPackage.signaturePrivateKey field as seed || pub.
const bobSigPriv = bobKp.privatePackage.signaturePrivateKey;
const bobSigPub = bobKp.publicPackage.leafNode.signaturePublicKey;
// Wrap Bob's KeyPackage in an MlsMessage (matches the shape Amethyst decodes)
const bobKpWire = {
version: protocolVersions.mls10,
wireformat: wireformats.mls_key_package,
keyPackage: bobKp.publicPackage,
};
const bobKpMsgBytes = encode(mlsMessageEncoder, bobKpWire);
const bobKpRawBytes = encode(keyPackageEncoder, bobKp.publicPackage);
const aliceSigPub = aliceKp.publicPackage.leafNode.signaturePublicKey;
// ts-mls returns Ed25519 signature private keys as PKCS#8-DER envelopes
// (~48 bytes: a fixed 16-byte ASN.1 header then the 32-byte seed at the
// tail). openmls returns the raw 32-byte seed, and Amethyst expects
// seed || pub. Normalise to the raw 32-byte seed here; the Kotlin test
// appends signature_pub back on to rebuild the 64-byte form.
const sigSeed =
bobSigPriv.length === 32
? bobSigPriv
: bobSigPriv.length === 64
? bobSigPriv.slice(0, 32)
: bobSigPriv.slice(bobSigPriv.length - 32); // PKCS#8: seed is at the end
const vector = {
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: hex(bobKp.privatePackage.initPrivateKey),
encryption_priv: hex(bobKp.privatePackage.hpkePrivateKey),
signature_priv: hex(sigSeed),
signature_pub: hex(bobSigPub),
key_package: hex(bobKpMsgBytes),
key_package_raw: hex(bobKpRawBytes),
},
committer: {
signer_pub: hex(aliceSigPub),
},
welcome: hex(welcomeBytes),
exporter: {
label: exporterLabel,
context: hex(exporterContext),
length: exporterLength,
secret: hex(exporterSecret),
},
app_messages_alice_to_bob: appMessages,
};
process.stdout.write(JSON.stringify(vector, null, 2) + "\n");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,12 @@
{
"name": "tsmls-vector-gen",
"version": "0.1.0",
"type": "module",
"private": true,
"dependencies": {
"@noble/ciphers": "^2.2.0",
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.2.0",
"ts-mls": "2.0.0-rc.10"
}
}
@@ -0,0 +1,153 @@
// Reverse-interop verifier: Amethyst → ts-mls.
//
// Reads two JSON files:
// $1 — the joiner-KP handoff produced by emit-joiner-kp.mjs
// (contains Bob's private keys in ts-mls-compatible form).
// $2 — the Amethyst-authored fixture (produced by the Kotlin
// AmethystAuthoredVectorGen test) containing Alice's Welcome
// and three PrivateMessages she sent to Bob.
//
// Succeeds (exit 0) if Bob can:
// 1. decode Amethyst's KeyPackage wrapper (basic wire-format sanity),
// 2. join Alice's group from Amethyst's Welcome,
// 3. decrypt each of Alice's application messages and match the
// expected plaintext.
import { readFileSync } from "node:fs";
import {
decode,
defaultCryptoProvider,
getCiphersuiteImpl,
joinGroup,
keyPackageDecoder,
mlsMessageDecoder,
processPrivateMessage,
unsafeTestingAuthenticationService,
wireformats,
} from "ts-mls";
function assert(cond, msg) {
if (!cond) {
console.error("FAIL:", msg);
process.exit(1);
}
}
const hexToBytes = (s) =>
Uint8Array.from(s.match(/../g).map((b) => parseInt(b, 16)));
const [handoffPath, fixturePath] = process.argv.slice(2);
if (!handoffPath || !fixturePath) {
console.error(
"usage: verify-amethyst-fixture.mjs <joiner-handoff.json> <amethyst-fixture.json>",
);
process.exit(2);
}
const handoff = JSON.parse(readFileSync(handoffPath, "utf8"));
const fixture = JSON.parse(readFileSync(fixturePath, "utf8"));
const cs = await getCiphersuiteImpl(
"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519",
defaultCryptoProvider,
);
const ctx = {
cipherSuite: cs,
authService: unsafeTestingAuthenticationService,
};
// Rebuild Bob's public+private KP from the handoff.
const kpBytes = hexToBytes(handoff.joiner.key_package_raw);
const kpPub = decode(keyPackageDecoder, kpBytes);
assert(kpPub, "failed to decode joiner key_package_raw");
// ts-mls signature_priv is a PKCS#8 Ed25519 DER envelope. Reconstruct
// it from the raw 32-byte seed emitted by the handoff.
const PKCS8_ED25519_PREFIX = hexToBytes("302e020100300506032b657004220420");
const seed = hexToBytes(handoff.joiner.signature_priv);
const sigPrivDer = new Uint8Array(PKCS8_ED25519_PREFIX.length + seed.length);
sigPrivDer.set(PKCS8_ED25519_PREFIX, 0);
sigPrivDer.set(seed, PKCS8_ED25519_PREFIX.length);
const privateKeys = {
initPrivateKey: hexToBytes(handoff.joiner.init_priv),
hpkePrivateKey: hexToBytes(handoff.joiner.encryption_priv),
signaturePrivateKey: sigPrivDer,
};
// Decode Amethyst's Welcome (wrapped in MlsMessage).
const welcomeBytes = hexToBytes(fixture.welcome);
const welcomeMsg = decode(mlsMessageDecoder, welcomeBytes);
assert(welcomeMsg, "failed to decode Amethyst Welcome bytes");
assert(
welcomeMsg.wireformat === wireformats.mls_welcome,
"Amethyst Welcome wire-format mismatch",
);
// Join.
let state;
try {
state = await joinGroup({
context: ctx,
welcome: welcomeMsg.welcome,
keyPackage: kpPub,
privateKeys,
});
} catch (e) {
console.error("FAIL: joinGroup threw:", e?.message ?? e);
process.exit(1);
}
console.log("PASS: joined Amethyst-authored Welcome at epoch", state.groupContext.epoch);
// Dump the decoded GroupInfo for diagnostic use by other verifiers.
// ts-mls already unwrapped it during joinGroup, so we just print a
// compact summary + the full extensions array.
{
const gc = state.groupContext;
const ext = state.publicGroupState?.groupInfoExtensions;
// (state layout varies; these fields may or may not exist. Best-effort.)
if (process.env.DUMP_GROUP_INFO) {
console.log(JSON.stringify({ groupContext: gc, extensions: ext }, (_k, v) =>
v instanceof Uint8Array ? Array.from(v).map(b => b.toString(16).padStart(2, "0")).join("") : v,
2));
}
}
// Decrypt each application message.
for (const [idx, msg] of fixture.app_messages_alice_to_bob.entries()) {
const msgBytes = hexToBytes(msg.private_message);
const framedMsg = decode(mlsMessageDecoder, msgBytes);
assert(framedMsg, `failed to decode application message ${idx}`);
assert(
framedMsg.wireformat === wireformats.mls_private_message,
`application message ${idx} wire-format mismatch`,
);
let res;
try {
res = await processPrivateMessage({
context: ctx,
state,
privateMessage: framedMsg.privateMessage,
});
} catch (e) {
console.error(`FAIL: processPrivateMessage(${idx}) threw:`, e?.message ?? e);
process.exit(1);
}
state = res.newState;
if (res.kind !== "applicationMessage") {
console.error(`FAIL: processPrivateMessage(${idx}) returned kind=${res.kind}, expected applicationMessage`);
process.exit(1);
}
const gotHex = Array.from(res.message ?? new Uint8Array(), (b) =>
b.toString(16).padStart(2, "0"),
).join("");
if (gotHex !== msg.plaintext) {
console.error(
`FAIL: app message ${idx} plaintext mismatch\n want: ${msg.plaintext}\n got : ${gotHex}`,
);
process.exit(1);
}
console.log(`PASS: decrypted Amethyst app message ${idx}`);
}
console.log("ALL PASS — ts-mls read every Amethyst-authored artifact.");