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