test(marmot): MDK-authored Welcome interop vector + decryptor
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<V> +
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
This commit is contained in:
@@ -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"
|
||||
}
|
||||
+217
@@ -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<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: MdkVector =
|
||||
JsonMapper.jsonInstance.decodeFromString<MdkVector>(
|
||||
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<V>; // 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<V>` + `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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user