From 91f5d21cc3744d728a417b7804a71deccab65ebd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 05:04:41 +0000 Subject: [PATCH] test(marmot): MDK-authored Welcome interop vector + decryptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Rust generator under quartz/tools/mdk-vector-gen that emits a Welcome, joiner KeyPackage, committer signer_pub, joiner's three private keys, and a post-join MLS-Exporter KAT, using the same openmls 0.8 backend MDK / whitenoise use. Commit its output at quartz/src/commonTest/resources/mls/mdk-welcome.json. MdkWelcomeInteropTest consumes the vector and proves Amethyst can: - parse a KeyPackage OpenMLS authored and verify its self-signature; - run the joiner's processWelcome end-to-end (HPKE unwrap of group secrets, welcome-key derivation, AEAD GroupInfo decrypt, ratchet tree decode, signer leaf lookup, GroupInfoTBS Ed25519 verify); - derive MLS-Exporter("marmot", "group-event", 32) byte-for-byte identically to openmls — the exact exporter Marmot uses for the outer ChaCha20 wrap on kind:445 events. testBobDecryptsMdkApplicationMessagesFromAlice is @Ignored with a detailed TODO because Amethyst's MlsGroup.encrypt/decrypt skip the RFC 9420 §6.3.1 PrivateMessageContent framing (application_data + FramedContentAuthData + padding). Amethyst ↔ Amethyst works (both sides omit it) but every cross-implementation PrivateMessage fails. Tracked as a follow-up. https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr --- .../commonTest/resources/mls/mdk-welcome.json | 36 +++ .../marmot/mls/MdkWelcomeInteropTest.kt | 217 ++++++++++++++++++ quartz/tools/mdk-vector-gen/.gitignore | 2 + quartz/tools/mdk-vector-gen/Cargo.toml | 14 ++ quartz/tools/mdk-vector-gen/README.md | 36 +++ quartz/tools/mdk-vector-gen/src/main.rs | 144 ++++++++++++ 6 files changed, 449 insertions(+) create mode 100644 quartz/src/commonTest/resources/mls/mdk-welcome.json create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt create mode 100644 quartz/tools/mdk-vector-gen/.gitignore create mode 100644 quartz/tools/mdk-vector-gen/Cargo.toml create mode 100644 quartz/tools/mdk-vector-gen/README.md create mode 100644 quartz/tools/mdk-vector-gen/src/main.rs 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 0000000000..d03751a0fa --- /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/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 0000000000..b3ef8503c4 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt @@ -0,0 +1,217 @@ +/* + * 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.Ignore +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") + } + + /** + * Application-message interop currently fails because Amethyst's + * [MlsGroup.encrypt] / [MlsGroup.decrypt] treat the AEAD plaintext as + * raw application bytes — they skip the [RFC 9420 §6.3.1 + * PrivateMessageContent] framing: + * + * struct { + * opaque application_data; // varint-prefixed payload + * FramedContentAuthData auth; // signature over FramedContentTBS + * opaque padding[N]; // zero-padding + * } PrivateMessageContent; + * + * Amethyst ↔ Amethyst round-trips pass (both sides omit framing), but + * when openmls/MDK/whitenoise sends a PrivateMessage Amethyst's + * `.decrypt` returns the entire PrivateMessageContent serialization — + * so plaintext comparisons fail (we see `0x0a 'H' 'e' ... auth... padding` + * instead of `'Hello Bob!'`). + * + * Full fix requires: (a) sign FramedContentTBS on the send side with a + * MAC over Application/Proposal and a confirmation_tag over Commit; + * (b) parse `application_data` + `FramedContentAuthData` + padding + * on the receive side; (c) verify the signature / confirmation tag. + * Tracked separately — keeping this test as a reproducer. + */ + @Ignore + @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/tools/mdk-vector-gen/.gitignore b/quartz/tools/mdk-vector-gen/.gitignore new file mode 100644 index 0000000000..4fffb2f89c --- /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 0000000000..43749beb10 --- /dev/null +++ b/quartz/tools/mdk-vector-gen/Cargo.toml @@ -0,0 +1,14 @@ +[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_traits = "0.5" +tls_codec = "0.4" +hex = "0.4" +serde_json = "1" +serde = { version = "1", features = ["derive"] } diff --git a/quartz/tools/mdk-vector-gen/README.md b/quartz/tools/mdk-vector-gen/README.md new file mode 100644 index 0000000000..51a35eb964 --- /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/main.rs b/quartz/tools/mdk-vector-gen/src/main.rs new file mode 100644 index 0000000000..a1b5615936 --- /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()); +}