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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+417
-72
@@ -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
|
||||
)
|
||||
|
||||
+28
-6
@@ -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}'",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+319
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-2
@@ -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()
|
||||
|
||||
+214
@@ -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,
|
||||
)
|
||||
}
|
||||
+205
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
-7
@@ -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())
|
||||
|
||||
-7
@@ -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())
|
||||
|
||||
+180
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user