diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt index 9d723649a..fcc7990cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt @@ -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) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index a8d99382e..da84e71d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -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; + * FramedContentAuthData auth; // = opaque signature + * 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 + pmcWriter.putOpaqueVarInt(signature) // FramedContentAuthData.signature + // (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 + // 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, + ): Pair, ByteArray> = computeSenderParentHashes(tree, senderLeafIndex, preUpdateSiblingHashes) + + private fun computeSenderParentHashes( + tree: com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree, + senderLeafIndex: Int, + preUpdateSiblingHashes: Map, + ): Pair, ByteArray> { + val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount) + if (directPath.isEmpty()) return Pair(emptyMap(), ByteArray(0)) + + val hashes = mutableMapOf() + // 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; + * opaque original_sibling_tree_hash; + * } 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 { 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, + ): Pair, ByteArray> { + val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount) + if (directPath.isEmpty()) return Pair(emptyMap(), ByteArray(0)) + + val hashes = mutableMapOf() + 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() + 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 ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt index d1593f22c..a4df60ed3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt @@ -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}'", + ) + } + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt index 183196718..e87e4425a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt @@ -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 = + JsonMapper.jsonInstance + .decodeFromString>( + 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", + ) + } + } } diff --git a/quartz/src/commonTest/resources/mls/mdk-welcome.json b/quartz/src/commonTest/resources/mls/mdk-welcome.json new file mode 100644 index 000000000..d03751a0f --- /dev/null +++ b/quartz/src/commonTest/resources/mls/mdk-welcome.json @@ -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" +} diff --git a/quartz/src/commonTest/resources/mls/tsmls-welcome.json b/quartz/src/commonTest/resources/mls/tsmls-welcome.json new file mode 100644 index 000000000..e03c08eef --- /dev/null +++ b/quartz/src/commonTest/resources/mls/tsmls-welcome.json @@ -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" + } + ] +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt index 9570346f6..f8a2cc345 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt @@ -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() diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/AmethystAuthoredVectorGen.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/AmethystAuthoredVectorGen.kt new file mode 100644 index 000000000..1bf2935a0 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/AmethystAuthoredVectorGen.kt @@ -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(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, + 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, + ) +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt new file mode 100644 index 000000000..38eb36637 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt @@ -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 = 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( + 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; // varint-prefixed payload + * opaque signature; // 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", + ) + } + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsConformanceTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsConformanceTest.kt index 1e975a809..86459eb11 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsConformanceTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsConformanceTest.kt @@ -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) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt index d77ff1e89..dd7add392 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt @@ -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()) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt index 3a829233d..c067facda 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt @@ -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()) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TsMlsWelcomeInteropTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TsMlsWelcomeInteropTest.kt new file mode 100644 index 000000000..244b6c0c0 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TsMlsWelcomeInteropTest.kt @@ -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 = 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( + 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", + ) + } + } +} diff --git a/quartz/tools/mdk-vector-gen/.gitignore b/quartz/tools/mdk-vector-gen/.gitignore new file mode 100644 index 000000000..4fffb2f89 --- /dev/null +++ b/quartz/tools/mdk-vector-gen/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/quartz/tools/mdk-vector-gen/Cargo.toml b/quartz/tools/mdk-vector-gen/Cargo.toml new file mode 100644 index 000000000..853d15b0e --- /dev/null +++ b/quartz/tools/mdk-vector-gen/Cargo.toml @@ -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" diff --git a/quartz/tools/mdk-vector-gen/README.md b/quartz/tools/mdk-vector-gen/README.md new file mode 100644 index 000000000..51a35eb96 --- /dev/null +++ b/quartz/tools/mdk-vector-gen/README.md @@ -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. diff --git a/quartz/tools/mdk-vector-gen/src/emit_joiner_kp.rs b/quartz/tools/mdk-vector-gen/src/emit_joiner_kp.rs new file mode 100644 index 000000000..6abee9d3c --- /dev/null +++ b/quartz/tools/mdk-vector-gen/src/emit_joiner_kp.rs @@ -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 = env::args().collect(); + if args.len() != 2 { + eprintln!("usage: emit-joiner-kp "); + 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 = (**bundle.init_private_key()).to_vec(); + let enc_priv: Vec = (**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()); +} diff --git a/quartz/tools/mdk-vector-gen/src/main.rs b/quartz/tools/mdk-vector-gen/src/main.rs new file mode 100644 index 000000000..a1b561593 --- /dev/null +++ b/quartz/tools/mdk-vector-gen/src/main.rs @@ -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 = (**bob_kp_bundle.init_private_key()).to_vec(); + let bob_enc_priv: Vec = (**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()); +} diff --git a/quartz/tools/mdk-vector-gen/src/verify_amethyst.rs b/quartz/tools/mdk-vector-gen/src/verify_amethyst.rs new file mode 100644 index 000000000..d9e90f8e6 --- /dev/null +++ b/quartz/tools/mdk-vector-gen/src/verify_amethyst.rs @@ -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, +} + +#[derive(Deserialize)] +struct AppMessage { + plaintext: String, + private_message: String, +} + +fn hex_bytes(s: &str) -> Vec { + 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 = env::args().collect(); + if args.len() != 4 { + eprintln!( + "usage: verify-amethyst \ + " + ); + 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, + } + 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."); +} diff --git a/quartz/tools/tsmls-vector-gen/.gitignore b/quartz/tools/tsmls-vector-gen/.gitignore new file mode 100644 index 000000000..d50251241 --- /dev/null +++ b/quartz/tools/tsmls-vector-gen/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/package-lock.json diff --git a/quartz/tools/tsmls-vector-gen/README.md b/quartz/tools/tsmls-vector-gen/README.md new file mode 100644 index 000000000..cbc4a43ad --- /dev/null +++ b/quartz/tools/tsmls-vector-gen/README.md @@ -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. diff --git a/quartz/tools/tsmls-vector-gen/emit-joiner-kp.mjs b/quartz/tools/tsmls-vector-gen/emit-joiner-kp.mjs new file mode 100644 index 000000000..b9b6e06bf --- /dev/null +++ b/quartz/tools/tsmls-vector-gen/emit-joiner-kp.mjs @@ -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"); diff --git a/quartz/tools/tsmls-vector-gen/generate.mjs b/quartz/tools/tsmls-vector-gen/generate.mjs new file mode 100644 index 000000000..60f21df2f --- /dev/null +++ b/quartz/tools/tsmls-vector-gen/generate.mjs @@ -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); +}); diff --git a/quartz/tools/tsmls-vector-gen/package.json b/quartz/tools/tsmls-vector-gen/package.json new file mode 100644 index 000000000..92bf36521 --- /dev/null +++ b/quartz/tools/tsmls-vector-gen/package.json @@ -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" + } +} diff --git a/quartz/tools/tsmls-vector-gen/verify-amethyst-fixture.mjs b/quartz/tools/tsmls-vector-gen/verify-amethyst-fixture.mjs new file mode 100644 index 000000000..571e40161 --- /dev/null +++ b/quartz/tools/tsmls-vector-gen/verify-amethyst-fixture.mjs @@ -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 ", + ); + 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.");