From cd12018e089d04ff7e7c545cb88eba53e453691b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 13:34:32 +0000 Subject: [PATCH] test(marmot): add ts-mls interop vector + decryptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit marmot-ts (the TypeScript Marmot client — browsers, Node, Bun, Deno) wraps a completely different MLS implementation: ts-mls. That backend shares no code with OpenMLS, which is what MDK/whitenoise use, so Amethyst passing on both is a strong signal that the on-wire MLS layer is spec-correct rather than coincidentally self-consistent. Add a Node-based generator under quartz/tools/tsmls-vector-gen/ that uses ts-mls 2.0.0-rc.10 directly to emit: - Alice's KeyPackage + Bob's joiner KeyPackage - Alice's Welcome after add_member + commit - Bob's init/encryption/signature private keys (PKCS#8 Ed25519 unwrapped to the raw 32-byte seed for parity with the MDK vector shape) - Post-join MLS-Exporter("marmot","group-event",32) KAT - Three application PrivateMessages from Alice → Bob with the expected plaintexts TsMlsWelcomeInteropTest mirrors MdkWelcomeInteropTest but consumes the new vector. It proves: - KeyPackage self-signature verifies under our Ed25519 + SignContent. - Amethyst.processWelcome unwraps the group secrets, derives the welcome_key/nonce, AEAD-decrypts GroupInfo, verifies GroupInfoTBS with signer_pub, decodes the ratchet tree, and binds the joiner's leaf — end-to-end. - Post-join exporter secret matches ts-mls byte-for-byte. - Amethyst decrypt() parses the RFC 9420 §6.3.1 PrivateMessageContent framing (application_data + FramedContentAuthData.signature + zero padding) and verifies the sender's FramedContentTBS signature against Alice's leaf, for all three ciphertexts. https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr --- .../resources/mls/tsmls-welcome.json | 36 ++++ .../marmot/mls/TsMlsWelcomeInteropTest.kt | 180 +++++++++++++++++ quartz/tools/tsmls-vector-gen/.gitignore | 2 + quartz/tools/tsmls-vector-gen/README.md | 26 +++ quartz/tools/tsmls-vector-gen/generate.mjs | 190 ++++++++++++++++++ quartz/tools/tsmls-vector-gen/package.json | 12 ++ 6 files changed, 446 insertions(+) create mode 100644 quartz/src/commonTest/resources/mls/tsmls-welcome.json create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TsMlsWelcomeInteropTest.kt create mode 100644 quartz/tools/tsmls-vector-gen/.gitignore create mode 100644 quartz/tools/tsmls-vector-gen/README.md create mode 100644 quartz/tools/tsmls-vector-gen/generate.mjs create mode 100644 quartz/tools/tsmls-vector-gen/package.json 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/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/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/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" + } +}