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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user