test(marmot): add ts-mls interop vector + decryptor
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<V> + FramedContentAuthData.signature<V> +
zero padding) and verifies the sender's FramedContentTBS signature
against Alice's leaf, for all three ciphertexts.
https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.marmot.mls
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.TestResourceLoader
|
||||||
|
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||||
|
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
|
||||||
|
import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat
|
||||||
|
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||||
|
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
|
||||||
|
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interop test against a Welcome authored by ts-mls — the TypeScript MLS
|
||||||
|
* implementation that powers marmot-ts (the Marmot client for browsers /
|
||||||
|
* Node / Bun / Deno). ts-mls is a completely independent codebase from
|
||||||
|
* OpenMLS (MDK's backend), so a clean pass here means Amethyst agrees
|
||||||
|
* byte-for-byte with two MLS implementations on every crypto surface
|
||||||
|
* Marmot touches.
|
||||||
|
*
|
||||||
|
* Vector is regenerated by `quartz/tools/tsmls-vector-gen`.
|
||||||
|
*/
|
||||||
|
class TsMlsWelcomeInteropTest {
|
||||||
|
@Serializable
|
||||||
|
private data class TsMlsVector(
|
||||||
|
@SerialName("cipher_suite") val cipherSuite: Int,
|
||||||
|
val description: String,
|
||||||
|
val joiner: Joiner,
|
||||||
|
val committer: Committer,
|
||||||
|
val welcome: String,
|
||||||
|
val exporter: Exporter,
|
||||||
|
@SerialName("app_messages_alice_to_bob")
|
||||||
|
val appMessages: List<AppMessage> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class AppMessage(
|
||||||
|
val plaintext: String,
|
||||||
|
@SerialName("private_message") val privateMessage: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class Joiner(
|
||||||
|
@SerialName("init_priv") val initPriv: String,
|
||||||
|
@SerialName("encryption_priv") val encryptionPriv: String,
|
||||||
|
@SerialName("signature_priv") val signaturePriv: String,
|
||||||
|
@SerialName("signature_pub") val signaturePub: String,
|
||||||
|
@SerialName("key_package") val keyPackage: String,
|
||||||
|
@SerialName("key_package_raw") val keyPackageRaw: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class Committer(
|
||||||
|
@SerialName("signer_pub") val signerPub: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class Exporter(
|
||||||
|
val label: String,
|
||||||
|
val context: String,
|
||||||
|
val length: Int,
|
||||||
|
val secret: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val vector: TsMlsVector =
|
||||||
|
JsonMapper.jsonInstance.decodeFromString<TsMlsVector>(
|
||||||
|
TestResourceLoader().loadString("mls/tsmls-welcome.json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTsMlsKeyPackageRoundTripAndSignature() {
|
||||||
|
assertEquals(1, vector.cipherSuite)
|
||||||
|
|
||||||
|
val kpMsg = MlsMessage.decodeTls(TlsReader(vector.joiner.keyPackage.hexToByteArray()))
|
||||||
|
assertEquals(WireFormat.KEY_PACKAGE, kpMsg.wireFormat)
|
||||||
|
|
||||||
|
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
|
||||||
|
assertContentEquals(
|
||||||
|
vector.joiner.keyPackageRaw.hexToByteArray(),
|
||||||
|
kp.toTlsBytes(),
|
||||||
|
"Inner KeyPackage bytes should round-trip",
|
||||||
|
)
|
||||||
|
assertTrue(kp.verifySignature(), "ts-mls-authored KeyPackage signature must verify")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testProcessTsMlsWelcomeAndDeriveExporterSecret() {
|
||||||
|
val kpMsg = MlsMessage.decodeTls(TlsReader(vector.joiner.keyPackage.hexToByteArray()))
|
||||||
|
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
|
||||||
|
|
||||||
|
// Amethyst's Ed25519 expects seed || pub (64 bytes); the generator
|
||||||
|
// emits the 32-byte seed and pub separately.
|
||||||
|
val sigPriv =
|
||||||
|
vector.joiner.signaturePriv.hexToByteArray() +
|
||||||
|
vector.joiner.signaturePub.hexToByteArray()
|
||||||
|
|
||||||
|
val bundle =
|
||||||
|
KeyPackageBundle(
|
||||||
|
keyPackage = kp,
|
||||||
|
initPrivateKey = vector.joiner.initPriv.hexToByteArray(),
|
||||||
|
encryptionPrivateKey = vector.joiner.encryptionPriv.hexToByteArray(),
|
||||||
|
signaturePrivateKey = sigPriv,
|
||||||
|
)
|
||||||
|
|
||||||
|
val group = MlsGroup.processWelcome(vector.welcome.hexToByteArray(), bundle)
|
||||||
|
|
||||||
|
val ourExporter =
|
||||||
|
group.exporterSecret(
|
||||||
|
label = vector.exporter.label,
|
||||||
|
context = vector.exporter.context.hexToByteArray(),
|
||||||
|
length = vector.exporter.length,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
vector.exporter.secret,
|
||||||
|
ourExporter.toHexKey(),
|
||||||
|
"MLS-Exporter disagreement post-join against ts-mls",
|
||||||
|
)
|
||||||
|
assertNotNull(group, "processWelcome returned null")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBobDecryptsTsMlsApplicationMessagesFromAlice() {
|
||||||
|
assertTrue(
|
||||||
|
vector.appMessages.isNotEmpty(),
|
||||||
|
"tsmls-welcome.json should ship at least one app_messages_alice_to_bob entry",
|
||||||
|
)
|
||||||
|
|
||||||
|
val kpMsg = MlsMessage.decodeTls(TlsReader(vector.joiner.keyPackage.hexToByteArray()))
|
||||||
|
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
|
||||||
|
val sigPriv =
|
||||||
|
vector.joiner.signaturePriv.hexToByteArray() +
|
||||||
|
vector.joiner.signaturePub.hexToByteArray()
|
||||||
|
val bundle =
|
||||||
|
KeyPackageBundle(
|
||||||
|
keyPackage = kp,
|
||||||
|
initPrivateKey = vector.joiner.initPriv.hexToByteArray(),
|
||||||
|
encryptionPrivateKey = vector.joiner.encryptionPriv.hexToByteArray(),
|
||||||
|
signaturePrivateKey = sigPriv,
|
||||||
|
)
|
||||||
|
|
||||||
|
val bob = MlsGroup.processWelcome(vector.welcome.hexToByteArray(), bundle)
|
||||||
|
|
||||||
|
for ((idx, msg) in vector.appMessages.withIndex()) {
|
||||||
|
val decrypted = bob.decrypt(msg.privateMessage.hexToByteArray())
|
||||||
|
assertContentEquals(
|
||||||
|
msg.plaintext.hexToByteArray(),
|
||||||
|
decrypted.content,
|
||||||
|
"ts-mls application message $idx plaintext mismatch",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/node_modules
|
||||||
|
/package-lock.json
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# tsmls-vector-gen
|
||||||
|
|
||||||
|
Node helper that emits MLS interop test vectors from `ts-mls`, the
|
||||||
|
TypeScript MLS implementation that powers
|
||||||
|
[marmot-ts](https://github.com/marmot-protocol/marmot-ts) (the Marmot
|
||||||
|
protocol client that runs in the browser / Node / Bun / Deno).
|
||||||
|
|
||||||
|
Produces `quartz/src/commonTest/resources/mls/tsmls-welcome.json`, which
|
||||||
|
`TsMlsWelcomeInteropTest` consumes to prove Amethyst can parse and
|
||||||
|
decrypt a Welcome + application messages authored by the TypeScript
|
||||||
|
side. Together with `mdk-vector-gen` (OpenMLS/Rust) the Marmot suite now
|
||||||
|
has cross-implementation KATs from two independent MLS backends.
|
||||||
|
|
||||||
|
## Regenerating
|
||||||
|
|
||||||
|
```
|
||||||
|
cd quartz/tools/tsmls-vector-gen
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
node generate.mjs > ../../src/commonTest/resources/mls/tsmls-welcome.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The generator uses fresh randomness each run; commit the regenerated
|
||||||
|
JSON if you change the generator. `ts-mls` returns Ed25519 signature
|
||||||
|
private keys as PKCS#8-DER envelopes (16-byte header + 32-byte seed);
|
||||||
|
we strip the header before emitting so the vector's `signature_priv`
|
||||||
|
field is directly comparable to the one `mdk-vector-gen` produces.
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// Generate marmot-ts / ts-mls interop vectors for the Amethyst Marmot module.
|
||||||
|
//
|
||||||
|
// ts-mls is the TypeScript MLS implementation that powers marmot-ts (the
|
||||||
|
// Marmot protocol client that runs in the browser and on Node/Bun/Deno).
|
||||||
|
// This generator uses ts-mls directly at the MLS layer — the Nostr wrapping
|
||||||
|
// marmot-ts adds is orthogonal to the cipher, so an MLS-level interop match
|
||||||
|
// against ts-mls is the same interop property we'd get from running the
|
||||||
|
// marmot-ts client end-to-end.
|
||||||
|
//
|
||||||
|
// Output: a JSON document on stdout with the same shape as mdk-welcome.json,
|
||||||
|
// so the Amethyst MdkWelcomeInteropTest test harness can consume either.
|
||||||
|
|
||||||
|
import {
|
||||||
|
ciphersuites,
|
||||||
|
createApplicationMessage,
|
||||||
|
createCommit,
|
||||||
|
createGroup,
|
||||||
|
decode,
|
||||||
|
defaultCryptoProvider,
|
||||||
|
defaultLifetime,
|
||||||
|
encode,
|
||||||
|
generateKeyPackage,
|
||||||
|
getCiphersuiteImpl,
|
||||||
|
joinGroup,
|
||||||
|
keyPackageEncoder,
|
||||||
|
mlsExporter,
|
||||||
|
mlsMessageDecoder,
|
||||||
|
mlsMessageEncoder,
|
||||||
|
protocolVersions,
|
||||||
|
unsafeTestingAuthenticationService,
|
||||||
|
wireformats,
|
||||||
|
} from "ts-mls";
|
||||||
|
|
||||||
|
const hex = (bytes) =>
|
||||||
|
Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cs = await getCiphersuiteImpl(
|
||||||
|
"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519",
|
||||||
|
defaultCryptoProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
cipherSuite: cs,
|
||||||
|
authService: unsafeTestingAuthenticationService,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alice + Bob KeyPackages
|
||||||
|
const aliceKp = await generateKeyPackage({
|
||||||
|
credential: { credentialType: 1 /* basic */, identity: new TextEncoder().encode("alice") },
|
||||||
|
lifetime: defaultLifetime(),
|
||||||
|
cipherSuite: cs,
|
||||||
|
});
|
||||||
|
const bobKp = await generateKeyPackage({
|
||||||
|
credential: { credentialType: 1 /* basic */, identity: new TextEncoder().encode("bob") },
|
||||||
|
lifetime: defaultLifetime(),
|
||||||
|
cipherSuite: cs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alice creates the group
|
||||||
|
const groupId = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
const aliceState0 = await createGroup({
|
||||||
|
context: ctx,
|
||||||
|
groupId,
|
||||||
|
keyPackage: aliceKp.publicPackage,
|
||||||
|
privateKeyPackage: aliceKp.privatePackage,
|
||||||
|
extensions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alice adds Bob via a commit containing an Add proposal
|
||||||
|
const { newState: aliceState1, welcome, commit } = await createCommit({
|
||||||
|
context: ctx,
|
||||||
|
state: aliceState0,
|
||||||
|
extraProposals: [{ proposalType: 1 /* add */, add: { keyPackage: bobKp.publicPackage } }],
|
||||||
|
ratchetTreeExtension: true,
|
||||||
|
});
|
||||||
|
if (!welcome) throw new Error("createCommit did not produce a Welcome");
|
||||||
|
|
||||||
|
const welcomeBytes = encode(mlsMessageEncoder, welcome);
|
||||||
|
|
||||||
|
// Bob joins from the Welcome
|
||||||
|
const welcomeRoundTrip = decode(mlsMessageDecoder, welcomeBytes);
|
||||||
|
if (!welcomeRoundTrip || welcomeRoundTrip.wireformat !== wireformats.mls_welcome) {
|
||||||
|
throw new Error("round-trip welcome not of wire_format mls_welcome");
|
||||||
|
}
|
||||||
|
const bobState1 = await joinGroup({
|
||||||
|
context: ctx,
|
||||||
|
welcome: welcomeRoundTrip.welcome,
|
||||||
|
keyPackage: bobKp.publicPackage,
|
||||||
|
privateKeys: bobKp.privatePackage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// MLS-Exporter KAT: the "marmot" / "group-event" exporter used to seal
|
||||||
|
// kind:445 outer envelopes.
|
||||||
|
const exporterLabel = "marmot";
|
||||||
|
const exporterContext = new TextEncoder().encode("group-event");
|
||||||
|
const exporterLength = 32;
|
||||||
|
const exporterSecret = await mlsExporter(
|
||||||
|
bobState1.keySchedule.exporterSecret,
|
||||||
|
exporterLabel,
|
||||||
|
exporterContext,
|
||||||
|
exporterLength,
|
||||||
|
cs,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alice sends three application messages that Bob's side should decrypt.
|
||||||
|
// We ratchet aliceState forward between sends.
|
||||||
|
let aliceCursor = aliceState1;
|
||||||
|
const plaintexts = [
|
||||||
|
"Hello from ts-mls",
|
||||||
|
"Second message in the same epoch.",
|
||||||
|
"Unicode works too: ☕ ❤",
|
||||||
|
];
|
||||||
|
const appMessages = [];
|
||||||
|
for (const pt of plaintexts) {
|
||||||
|
const ptBytes = new TextEncoder().encode(pt);
|
||||||
|
const { newState, message } = await createApplicationMessage({
|
||||||
|
context: ctx,
|
||||||
|
state: aliceCursor,
|
||||||
|
message: ptBytes,
|
||||||
|
});
|
||||||
|
aliceCursor = newState;
|
||||||
|
const msgBytes = encode(mlsMessageEncoder, message);
|
||||||
|
appMessages.push({
|
||||||
|
plaintext: hex(ptBytes),
|
||||||
|
private_message: hex(msgBytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob's exported signature public key (we stored Ed25519 seed ourselves)
|
||||||
|
// ts-mls generateKeyPackage keeps the full signing-key pair in the
|
||||||
|
// PrivateKeyPackage.signaturePrivateKey field as seed || pub.
|
||||||
|
const bobSigPriv = bobKp.privatePackage.signaturePrivateKey;
|
||||||
|
const bobSigPub = bobKp.publicPackage.leafNode.signaturePublicKey;
|
||||||
|
|
||||||
|
// Wrap Bob's KeyPackage in an MlsMessage (matches the shape Amethyst decodes)
|
||||||
|
const bobKpWire = {
|
||||||
|
version: protocolVersions.mls10,
|
||||||
|
wireformat: wireformats.mls_key_package,
|
||||||
|
keyPackage: bobKp.publicPackage,
|
||||||
|
};
|
||||||
|
const bobKpMsgBytes = encode(mlsMessageEncoder, bobKpWire);
|
||||||
|
const bobKpRawBytes = encode(keyPackageEncoder, bobKp.publicPackage);
|
||||||
|
|
||||||
|
const aliceSigPub = aliceKp.publicPackage.leafNode.signaturePublicKey;
|
||||||
|
|
||||||
|
// ts-mls returns Ed25519 signature private keys as PKCS#8-DER envelopes
|
||||||
|
// (~48 bytes: a fixed 16-byte ASN.1 header then the 32-byte seed at the
|
||||||
|
// tail). openmls returns the raw 32-byte seed, and Amethyst expects
|
||||||
|
// seed || pub. Normalise to the raw 32-byte seed here; the Kotlin test
|
||||||
|
// appends signature_pub back on to rebuild the 64-byte form.
|
||||||
|
const sigSeed =
|
||||||
|
bobSigPriv.length === 32
|
||||||
|
? bobSigPriv
|
||||||
|
: bobSigPriv.length === 64
|
||||||
|
? bobSigPriv.slice(0, 32)
|
||||||
|
: bobSigPriv.slice(bobSigPriv.length - 32); // PKCS#8: seed is at the end
|
||||||
|
|
||||||
|
const vector = {
|
||||||
|
cipher_suite: 1,
|
||||||
|
description:
|
||||||
|
"Alice creates a group and welcomes Bob via ts-mls 2.0.0-rc.10 (marmot-ts's MLS backend).",
|
||||||
|
joiner: {
|
||||||
|
init_priv: hex(bobKp.privatePackage.initPrivateKey),
|
||||||
|
encryption_priv: hex(bobKp.privatePackage.hpkePrivateKey),
|
||||||
|
signature_priv: hex(sigSeed),
|
||||||
|
signature_pub: hex(bobSigPub),
|
||||||
|
key_package: hex(bobKpMsgBytes),
|
||||||
|
key_package_raw: hex(bobKpRawBytes),
|
||||||
|
},
|
||||||
|
committer: {
|
||||||
|
signer_pub: hex(aliceSigPub),
|
||||||
|
},
|
||||||
|
welcome: hex(welcomeBytes),
|
||||||
|
exporter: {
|
||||||
|
label: exporterLabel,
|
||||||
|
context: hex(exporterContext),
|
||||||
|
length: exporterLength,
|
||||||
|
secret: hex(exporterSecret),
|
||||||
|
},
|
||||||
|
app_messages_alice_to_bob: appMessages,
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdout.write(JSON.stringify(vector, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "tsmls-vector-gen",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "^2.2.0",
|
||||||
|
"@noble/curves": "^2.0.1",
|
||||||
|
"@noble/hashes": "^2.2.0",
|
||||||
|
"ts-mls": "2.0.0-rc.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user