diff --git a/cordn/README.md b/cordn/README.md index 3efa43f04c..c3575b8069 100644 --- a/cordn/README.md +++ b/cordn/README.md @@ -46,7 +46,23 @@ val group = MlsGroup.create( ) ``` -## Five things that are easy to get wrong +## Interoperability + +Tested against **ts-mls**, the implementation cordn's own client runs, using +fixtures generated by it. `interop/CordnLifecycleInteropTest` walks a whole +group lifecycle from the other side of the wire: read their KeyPackage, unseal +their commit, join from their Welcome, agree on the epoch exporter byte for +byte, apply their metadata commit, and read their application message. + +Fixtures come from **Staircase** (, +MIT), an independent Kotlin cordn client that vendors this project's own MLS +engine. See `src/jvmTest/resources/tsmls/README.md`. + +Agreeing on the epoch exporter is the assertion that carries the most: it sits +at the end of the entire key schedule, so a one-bit divergence in the tree, the +transcript hash or any epoch secret produces 32 completely different bytes. + +## Six things that are easy to get wrong 1. **A cordn group has no admins in the enforcement sense.** `spec/01.md` §5.3 makes `admin_pubkeys` *presentation* metadata, and neither the spec nor the @@ -74,7 +90,16 @@ val group = MlsGroup.create( a cursor that refused to move past it would stall that group permanently with no error anywhere. Only a *second* catch-up reveals the bug. -5. **Encryption must be pinned.** The ContextVM SDK defaults `encryptionMode` +5. **An application message MUST carry `authenticated_data`.** cordn puts the + sender's account pubkey in MLS `authenticated_data` and **rejects** any + application message that arrives with it empty + (`packages/cli/src/groupSync.ts:247`). The spec never mentions this; it is + only in the reference implementation. Use `CordnApplicationMessage`, not + `MlsGroup.encrypt` directly. It also means the binding costs metadata: the + field is authenticated but not encrypted, so the coordinator — which holds + every ciphertext — can read who sent what. + +6. **Encryption must be pinned.** The ContextVM SDK defaults `encryptionMode` to `OPTIONAL`, which resolves from negotiated session state, so a coordinator that simply does not announce `support_encryption` gets plaintext JSON-RPC on public relays — `gid`s, target pubkeys, KeyPackages and cursors, readable by @@ -101,7 +126,7 @@ partial: Plan §8 has the full analysis. -## Two spec/implementation divergences found +## Three spec/implementation divergences found 1. **`spec/01.md` §3 contradicts itself**: "MLS variable-length vector encoding conventions" and then `opaque Name<0..2^16-1>`, which are different @@ -109,7 +134,10 @@ Plan §8 has the full analysis. reference, and the test derives the expected bytes by hand from the spec rather than round-tripping — a round trip agrees with itself whichever one you picked. -2. **KeyPackage publication rides JSON-RPC envelope shape**, not a stable +2. **`authenticated_data` is a wire requirement the spec omits.** See item 5 + above. A client built from `spec/02.md` alone produces messages every cordn + peer discards. +3. **KeyPackage publication rides JSON-RPC envelope shape**, not a stable schema: `spec/00.md` §7's "signed publication payload" is the `kp_publish` request event, and the KeyPackage is recovered by parsing JSON-RPC out of its `content`. The reference client already carries a fallback from one rename @@ -140,9 +168,10 @@ vector — the plan's Tier D — rather than a record of our own output. Still open: - **Live integration** against `ghcr.io/cordn-msg/cordn:latest` (Tier B). -- **Group creation and messaging end to end.** The pieces are each tested; a - test that creates a group, adds a member through a Welcome and exchanges a - sealed envelope needs the Stage 0 interop vectors. +- **The reverse direction under their client.** We add a real ts-mls KeyPackage + to a group we created and produce a Welcome, but nothing here runs ts-mls to + confirm it can join. Staircase's conformance suite writes Kotlin-side + fixtures for exactly that exchange; wiring both halves is the remaining step. - **Multi-device** is an explicit non-goal — `spec/applications/multi-device.md` ships a ts-mls-internal serialization, not an MLS wire format. There is nothing to implement against. diff --git a/cordn/src/commonMain/kotlin/com/vitorpamplona/cordn/groups/CordnCredential.kt b/cordn/src/commonMain/kotlin/com/vitorpamplona/cordn/groups/CordnCredential.kt index 4934a9df3f..b69b10f8bb 100644 --- a/cordn/src/commonMain/kotlin/com/vitorpamplona/cordn/groups/CordnCredential.kt +++ b/cordn/src/commonMain/kotlin/com/vitorpamplona/cordn/groups/CordnCredential.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.cordn.groups +import com.vitorpamplona.quartz.mls.group.MlsGroup import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.mls.tree.LeafNode import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -79,6 +80,19 @@ object CordnCredential { /** The account hex claimed by [leaf], or null. */ fun identityOrNull(leaf: LeafNode?): HexKey? = identityOrNull(leaf?.credential) + /** + * Every member's account hex, by leaf index. + * + * Use this rather than `MlsGroup.memberIdentityHex`, which hex-encodes the + * credential bytes — correct for a binding that stores a raw key, and for + * cordn it returns 128 characters of hex-of-hex. Leaves whose credential is + * not a cordn identity are skipped rather than reported as garbage. + */ + fun membersOf(group: MlsGroup): Map = group.members().mapNotNull { (index, leaf) -> identityOrNull(leaf)?.let { index to it } }.toMap() + + /** The set of accounts holding at least one leaf. One account may hold several. */ + fun memberIdentities(group: MlsGroup): Set = membersOf(group).values.toSet() + private fun isCanonical(hex: String) = hex.length == HEX_LENGTH && hex.all { it in HEX_ALPHABET } private const val HEX_LENGTH = 64 diff --git a/cordn/src/commonMain/kotlin/com/vitorpamplona/cordn/groups/CordnGroupPolicy.kt b/cordn/src/commonMain/kotlin/com/vitorpamplona/cordn/groups/CordnGroupPolicy.kt index 1498746021..36ebf71b39 100644 --- a/cordn/src/commonMain/kotlin/com/vitorpamplona/cordn/groups/CordnGroupPolicy.kt +++ b/cordn/src/commonMain/kotlin/com/vitorpamplona/cordn/groups/CordnGroupPolicy.kt @@ -91,8 +91,6 @@ object CordnGroupPolicy : MlsGroupPolicy { */ override val defaultRequiredCapabilities: Extension? get() = null - override val knownExtensionTypes: Set get() = setOf(CordnGroupMetadata.EXTENSION_TYPE) - /** * `MLS-Exporter("cordn", "group-payload", 32)` — `spec/03.md` §4. * diff --git a/cordn/src/commonMain/kotlin/com/vitorpamplona/cordn/spec02Envelopes/CordnApplicationMessage.kt b/cordn/src/commonMain/kotlin/com/vitorpamplona/cordn/spec02Envelopes/CordnApplicationMessage.kt new file mode 100644 index 0000000000..63257f1966 --- /dev/null +++ b/cordn/src/commonMain/kotlin/com/vitorpamplona/cordn/spec02Envelopes/CordnApplicationMessage.kt @@ -0,0 +1,128 @@ +/* + * 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.cordn.spec02Envelopes + +import com.vitorpamplona.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.cordn.spec03Payloads.SealedPayload +import com.vitorpamplona.quartz.mls.group.DecryptedMessage +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Sending and receiving a cordn application message: envelope, MLS, seal. + * + * ## The authenticated-sender binding + * + * `spec/02.md` §5 says the envelope's `pubkey` must equal "the authenticated + * sender identity derived from the sender's MLS credential" and stops there. + * The reference implementation does something more specific that the spec never + * mentions: it puts the account pubkey in MLS **`authenticated_data`** + * (`packages/cli/src/session.ts:1000`) and, on receive, **rejects any + * application message whose AAD is empty** before even looking at the envelope + * (`packages/cli/src/groupSync.ts:247`). + * + * So a message without it is not merely unattributed, it is refused. That makes + * the AAD a wire requirement rather than an optimisation, and it is the reason + * this class exists instead of callers reaching for `MlsGroup.encrypt` directly. + * + * Using the AAD rather than the leaf credential also has a reason: the + * credential names whichever key holds the leaf, and a linked device's leaf is + * not the account. The AAD says who is *speaking*. + * + * `authenticated_data` is authenticated but **not encrypted**, so this binding + * costs metadata: any party holding the ciphertext can read the sender's + * account pubkey. The coordinator holds every ciphertext. cordn's reference + * client accepts that trade; it is worth knowing it was made. + */ +object CordnApplicationMessage { + /** + * Builds, frames and seals an application message. + * + * @return the base64 sealed payload to hand to `msg_post`. + */ + fun seal( + group: MlsGroup, + senderPubKey: HexKey, + envelope: CordnEnvelope, + ): String { + require(envelope.pubKey == senderPubKey) { + "envelope pubkey ${envelope.pubKey} does not match the sender $senderPubKey" + } + val mlsMessage = group.encrypt(envelope.encode(), authenticatedData = senderPubKey.encodeToByteArray()) + return SealedPayload.seal(mlsMessage, SealedPayload.applicationKey(group)) + } + + /** + * Opens a sealed application message and returns its envelope. + * + * Every check `spec/02.md` §5 and the reference client apply, in the order + * that makes each one meaningful: open the seal, let MLS authenticate the + * sender, read the sender from the AAD, then hold the envelope to it. + * + * @throws IllegalArgumentException naming the check that failed. + */ + fun open( + group: MlsGroup, + sealedBase64: String, + ): ReceivedMessage { + val mlsMessage = SealedPayload.open(sealedBase64, SealedPayload.applicationKey(group)) + val decrypted = group.decrypt(mlsMessage) + return open(decrypted) + } + + /** As [open], for a message some other path has already decrypted. */ + fun open(decrypted: DecryptedMessage): ReceivedMessage { + // Empty is a rejection, not a default. Treating it as "unknown sender" + // would let anyone drop the field and post as nobody in particular, + // which the envelope's own `pubkey` would then be free to fill in. + require(decrypted.authenticatedData.isNotEmpty()) { + "cordn application message carries no authenticated sender" + } + val sender = + try { + decrypted.authenticatedData.decodeToString(throwOnInvalidSequence = true) + } catch (e: CharacterCodingException) { + throw IllegalArgumentException("cordn authenticated sender is not valid UTF-8", e) + } + + return ReceivedMessage( + sender = sender, + senderLeafIndex = decrypted.senderLeafIndex, + epoch = decrypted.epoch, + // Holds the envelope to the MLS-authenticated sender, which is the + // only thing making an unsigned envelope trustworthy at all. + envelope = CordnEnvelope.decode(decrypted.content, senderIdentity = sender), + ) + } + + /** The exporter binding both directions use, for callers that need the key itself. */ + val exporter get() = CordnGroupPolicy.PAYLOAD_EXPORTER +} + +/** An application message that passed every authentication check. */ +data class ReceivedMessage( + /** The account pubkey MLS authenticated, from `authenticated_data`. */ + val sender: HexKey, + /** Which leaf sent it. Not the same as [sender] for a linked device. */ + val senderLeafIndex: Int, + val epoch: Long, + val envelope: CordnEnvelope, +) diff --git a/cordn/src/jvmTest/kotlin/com/vitorpamplona/cordn/interop/CordnApplicationMessageTest.kt b/cordn/src/jvmTest/kotlin/com/vitorpamplona/cordn/interop/CordnApplicationMessageTest.kt new file mode 100644 index 0000000000..17f7738160 --- /dev/null +++ b/cordn/src/jvmTest/kotlin/com/vitorpamplona/cordn/interop/CordnApplicationMessageTest.kt @@ -0,0 +1,119 @@ +/* + * 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.cordn.interop + +import com.vitorpamplona.cordn.groups.CordnCredential +import com.vitorpamplona.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.cordn.spec02Envelopes.CordnApplicationMessage +import com.vitorpamplona.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.mls.group.MlsGroup +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The SEND direction, which the ts-mls fixtures cannot cover. + * + * Those fixtures were written by ts-mls, so they prove we read what cordn + * writes. Nothing in them would notice if we stopped writing the + * `authenticated_data` binding on the way out — and cordn rejects an + * application message that arrives without it + * (`packages/cli/src/groupSync.ts:247`), so the symptom would be every peer + * silently dropping everything we send, with our own client perfectly happy. + */ +class CordnApplicationMessageTest { + private val alice = "aa".repeat(32) + private val bob = "bb".repeat(32) + + private fun group() = MlsGroup.create(identity = CordnCredential.of(alice).identity, policy = CordnGroupPolicy) + + private fun envelope(content: String = "hello") = CordnEnvelope.build(pubKey = alice, createdAt = 1_700_000_000L, kind = 9, content = content) + + @Test + fun `a sealed message carries the sender in authenticated_data`() { + val group = group() + val sealed = CordnApplicationMessage.seal(group, alice, envelope()) + + // Decrypt through a second view of the same epoch so the ratchet is not + // the thing under test. + val decrypted = + group.decrypt( + com.vitorpamplona.cordn.spec03Payloads.SealedPayload + .open( + sealed, + com.vitorpamplona.cordn.spec03Payloads.SealedPayload + .applicationKey(group), + ), + ) + + assertContentEquals( + alice.encodeToByteArray(), + decrypted.authenticatedData, + "cordn binds the sender here, and a peer refuses the message without it", + ) + } + + @Test + fun `the authenticated sender is what open reports, not the envelope`() { + val group = group() + val received = CordnApplicationMessage.open(group, CordnApplicationMessage.seal(group, alice, envelope())) + + assertEquals(alice, received.sender) + assertEquals("hello", received.envelope.content) + assertEquals(group.leafIndex, received.senderLeafIndex) + } + + @Test + fun `an envelope claiming a different pubkey is refused at the sender`() { + // Caught before it goes out rather than at every peer, which is the + // difference between an error here and a message nobody accepts. + val group = group() + val forged = CordnEnvelope.build(pubKey = bob, createdAt = 1_700_000_000L, kind = 9, content = "not me") + + assertFailsWith { CordnApplicationMessage.seal(group, alice, forged) } + } + + @Test + fun `a message with no authenticated sender is rejected on receive`() { + // The engine's default is an empty AAD, which is right for Marmot and + // fatal for cordn. Treating empty as "unknown sender" instead of a + // rejection would let anyone omit the field and let the envelope's own + // pubkey fill the gap unchallenged. + val group = group() + val bare = group.encrypt(envelope().encode()) + val decrypted = group.decrypt(bare) + + val error = assertFailsWith { CordnApplicationMessage.open(decrypted) } + assertTrue(error.message?.contains("authenticated sender") == true, "got '${error.message}'") + } + + @Test + fun `a round trip survives non-ASCII content`() { + val group = group() + val text = "reply at epoch 2 ✨ 🪜" + val received = CordnApplicationMessage.open(group, CordnApplicationMessage.seal(group, alice, envelope(text))) + + assertEquals(text, received.envelope.content) + assertEquals(received.envelope.computedId(), received.envelope.id) + } +} diff --git a/cordn/src/jvmTest/kotlin/com/vitorpamplona/cordn/interop/CordnLifecycleInteropTest.kt b/cordn/src/jvmTest/kotlin/com/vitorpamplona/cordn/interop/CordnLifecycleInteropTest.kt new file mode 100644 index 0000000000..56fd78b23f --- /dev/null +++ b/cordn/src/jvmTest/kotlin/com/vitorpamplona/cordn/interop/CordnLifecycleInteropTest.kt @@ -0,0 +1,284 @@ +/* + * 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.cordn.interop + +import com.vitorpamplona.cordn.appGroupRef.CordnGroupRef +import com.vitorpamplona.cordn.groups.CordnCredential +import com.vitorpamplona.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.cordn.spec02Envelopes.CordnApplicationMessage +import com.vitorpamplona.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.cordn.spec03Payloads.SealedPayload +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A full cordn group lifecycle, against fixtures **ts-mls** produced. + * + * Every other test in this module proves we agree with ourselves. This one + * proves we agree with the implementation cordn's own client runs: Alice + * creates a group in TypeScript, adds Bob, and sends a message, and we join as + * Bob and read it. + * + * Each step is a separate test because each fails for a different reason, and + * a single end-to-end assertion would tell you only that something in a chain + * of six broke. + */ +class CordnLifecycleInteropTest { + private val alice = TsMlsFixtures.text("alice.pk") + private val bob = TsMlsFixtures.text("bob.pk") + + private fun bobBundle() = TsMlsPrivateKeyPackage.decode(TsMlsFixtures.bytes("bob-kp.bin"), TsMlsFixtures.bytes("bob-privkp.bin")) + + /** Bob's group at epoch 1, joined from the ts-mls Welcome. */ + private fun bobJoined(): MlsGroup = MlsGroup.processWelcome(TsMlsFixtures.b64("welcome.b64"), bobBundle(), CordnGroupPolicy) + + // ---- 1. their KeyPackage, our decoder ---------------------------------- + + @Test + fun `we read a ts-mls KeyPackage and compute the same KeyPackageRef`() { + val keyPackage = MlsKeyPackage.decodeTls(TlsReader(TsMlsFixtures.bytes("bob-kp.bin"))) + + assertEquals( + bob, + CordnCredential.identityOrNull(keyPackage.leafNode), + "the credential must decode as cordn's 64-ASCII-hex identity", + ) + // kp_ref is the coordinator's primary key for a KeyPackage. Disagreeing + // here means every kp_take and every Welcome addressed to us misses. + assertEquals(TsMlsFixtures.text("bob-kpref.hex"), keyPackage.reference().toHexKey()) + } + + @Test + fun `we recognise a ts-mls last-resort KeyPackage`() { + // cordn marks it with app_data_dictionary component 0x0004; Marmot's + // MIP-era profile uses bare extension 0x000A. Our reader accepts both, + // and this pins the cordn carrier against a real one. + val lastResort = MlsKeyPackage.decodeTls(TlsReader(TsMlsFixtures.bytes("bob-lastresort-kp.bin"))) + assertTrue(lastResort.isLastResort()) + + val ordinary = MlsKeyPackage.decodeTls(TlsReader(TsMlsFixtures.bytes("bob-kp.bin"))) + assertTrue(!ordinary.isLastResort(), "and must not see it where there is none") + } + + @Test + fun `the ts-mls private key package round-trips through our codec`() { + val bundle = bobBundle() + assertEquals(32, TsMlsPrivateKeyPackage.seedOf(bundle.signaturePrivateKey).size) + assertContentEquals( + TsMlsFixtures.bytes("bob-privkp.bin"), + TsMlsPrivateKeyPackage.encode(bundle), + "re-encoding must reproduce ts-mls's bytes, PKCS#8 wrapper included", + ) + } + + // ---- 2. their seal, our exporter --------------------------------------- + + @Test + fun `we unseal a ts-mls commit with the published epoch-0 exporter`() { + // spec/03.md §5: a Commit is sealed under the epoch it transitions FROM, + // so every member still at that epoch can read it before advancing. + // Sealing it under the new epoch would lock out exactly the members it + // is addressed to. + val opened = SealedPayload.open(TsMlsFixtures.text("commit-add-sealed.b64"), TsMlsFixtures.hex("exporter-e0.hex")) + assertContentEquals(TsMlsFixtures.b64("commit-add.b64"), opened) + } + + @Test + fun `we unseal a ts-mls application message with the epoch-1 exporter`() { + val opened = SealedPayload.open(TsMlsFixtures.text("app-1-sealed.b64"), TsMlsFixtures.hex("exporter-e1.hex")) + assertContentEquals(TsMlsFixtures.b64("app-1.b64"), opened) + } + + // ---- 3. their Welcome, our join --------------------------------------- + + @Test + fun `we join the group from the ts-mls Welcome`() { + val group = bobJoined() + + assertEquals(1L, group.epoch, "the Welcome admits us at epoch 1") + assertEquals(bob, CordnCredential.identityOrNull(group.members()[group.leafIndex].second)) + assertEquals( + setOf(alice, bob), + CordnCredential.memberIdentities(group), + // Not group.currentMemberIdentities(): that hexes the credential + // bytes, which for cordn's already-hex identity gives 128 + // characters of hex-of-hex. The trap is real enough that cordn has + // its own accessor. + ) + } + + @Test + fun `our derived exporter matches theirs byte for byte`() { + // The single strongest assertion available. The exporter sits at the + // end of the whole key schedule, so agreeing on it means the tree, the + // transcript hash, the commit secret and every epoch secret matched. A + // one-bit divergence anywhere upstream produces 32 completely different + // bytes here. + val group = bobJoined() + val exporter = CordnGroupPolicy.PAYLOAD_EXPORTER + + assertContentEquals( + TsMlsFixtures.hex("exporter-e1.hex"), + group.exporterSecret(exporter.label, exporter.context, exporter.length), + ) + } + + @Test + fun `we read the group metadata ts-mls put in the GroupContext`() { + // Also proves the RFC 9420 §12.1.7 fix: the old engine refused any + // extension type outside a hardcoded list, and 0xC04D is not in it. + val metadata = CordnGroupMetadata.fromExtensions(bobJoined().extensions) + + assertEquals("Conformance", metadata?.name) + assertEquals(listOf(alice), metadata?.adminPubkeys) + assertEquals("🪜", metadata?.icon, "the emoji must survive as UTF-8") + assertTrue(!metadata!!.isEgalitarian, "this group names an admin") + } + + // ---- 4. their message, our reader ------------------------------------- + + @Test + fun `we open a ts-mls application message end to end`() { + // The whole path: unseal, MLS-decrypt, read the authenticated sender out + // of AAD, and hold the unsigned envelope to it. + val received = CordnApplicationMessage.open(bobJoined(), TsMlsFixtures.text("app-1-sealed.b64")) + + assertEquals(alice, received.sender, "the sender comes from authenticated_data, not the envelope") + assertEquals("hello from ts-mls", received.envelope.content) + assertEquals(9, received.envelope.kind) + assertEquals(bob, received.envelope.tags.single()[1], "the `p` tag names Bob") + } + + @Test + fun `the envelope id we recompute matches the one ts-mls wrote`() { + // spec/02.md §4 makes the receiver recompute it. If our NIP-01 + // serialization differed by so much as a space, every message would be + // rejected as tampered. + val expected = CordnEnvelope.decode(TsMlsFixtures.bytes("envelope-1.json"), senderIdentity = alice) + val received = CordnApplicationMessage.open(bobJoined(), TsMlsFixtures.text("app-1-sealed.b64")) + + assertEquals(expected.id, received.envelope.id) + assertEquals(expected.id, received.envelope.computedId()) + } + + // ---- 5. their metadata commit, our epoch advance ----------------------- + + /** + * Bob at epoch 2, having processed ts-mls's metadata commit. + * + * The commit arrives PRIVATE-framed, not public. That is a cordn/Marmot + * difference worth noticing: Marmot publishes commits as PublicMessage + * inside a kind-445 event, while cordn seals everything to the coordinator + * and frames handshake traffic privately, so `decrypt` is the entry point + * and `processFramedCommit` is not. + */ + private fun bobAtEpoch2(): MlsGroup { + val group = bobJoined() + val commit = SealedPayload.open(TsMlsFixtures.text("commit-meta-sealed.b64"), TsMlsFixtures.hex("exporter-e1.hex")) + val decrypted = group.decrypt(commit) + assertEquals(ContentType.COMMIT, decrypted.contentType, "cordn frames handshake messages privately") + return group + } + + @Test + fun `we apply a ts-mls GroupContextExtensions commit that renames the group`() { + // The strongest available check on the RFC 9420 §12.1.7 fix. Before it, + // the engine refused any extension type outside a hardcoded list, so + // this real commit -- carrying 0xC04D -- would have been rejected and + // Bob would have sat at epoch 1 forever while everyone else moved on. + val group = bobAtEpoch2() + + assertEquals(2L, group.epoch) + assertEquals("Conformance (renamed)", CordnGroupMetadata.fromExtensions(group.extensions)?.name) + assertEquals( + "https://example.invalid/g.png", + CordnGroupMetadata.fromExtensions(group.extensions)?.imageUrl, + ) + } + + @Test + fun `our exporter still matches theirs after the epoch advance`() { + // Joining agreed at epoch 1; this proves the commit itself -- tree + // mutation, transcript hash, key schedule -- agreed too. + val group = bobAtEpoch2() + val exporter = CordnGroupPolicy.PAYLOAD_EXPORTER + + assertContentEquals( + TsMlsFixtures.hex("exporter-e2.hex"), + group.exporterSecret(exporter.label, exporter.context, exporter.length), + ) + } + + @Test + fun `we read a threaded reply sent at epoch 2`() { + val received = CordnApplicationMessage.open(bobAtEpoch2(), TsMlsFixtures.text("app-2-sealed.b64")) + + assertEquals(alice, received.sender) + assertEquals(1111, received.envelope.kind, "NIP-22 threaded reply, per spec/02.md §6") + assertEquals("reply at epoch 2 \u2728", received.envelope.content) + assertEquals( + CordnEnvelope.decode(TsMlsFixtures.bytes("envelope-2.json"), senderIdentity = alice).id, + received.envelope.id, + ) + } + + // ---- 6. their KeyPackage, our group ------------------------------------ + + @Test + fun `we can add a ts-mls member to a group we created`() { + // The reverse direction, as far as it goes without running their client: + // a group built by our engine under CordnGroupPolicy accepts a real + // ts-mls KeyPackage and produces a Welcome for it. A capability or + // required_capabilities mismatch between the two profiles would fail + // exactly here. + val group = + MlsGroup.create( + identity = CordnCredential.of(alice).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(CordnGroupMetadata(name = "from Kotlin").toExtension()), + ) + + val result = group.addMember(TsMlsFixtures.bytes("bob2-kp.bin")) + + assertEquals(1L, group.epoch) + assertEquals(setOf(alice, bob), CordnCredential.memberIdentities(group)) + assertTrue(result.welcomeBytes != null, "a Welcome must be produced for the joiner") + } + + // ---- 7. the delivery id ------------------------------------------------ + + @Test + fun `the gid survives a group-ref round trip`() { + // spec/applications/group-ref.md §4.1: byte for byte, no normalisation. + // This gid is a plain string rather than a UUID, which is exactly the + // case a decoder that "tidied" its input would break. + val gid = TsMlsFixtures.text("gid") + assertEquals(gid, CordnGroupRef.decode(CordnGroupRef(gid).encode()).gid) + } +} diff --git a/cordn/src/jvmTest/kotlin/com/vitorpamplona/cordn/interop/TsMlsFixtures.kt b/cordn/src/jvmTest/kotlin/com/vitorpamplona/cordn/interop/TsMlsFixtures.kt new file mode 100644 index 0000000000..66fa7ae971 --- /dev/null +++ b/cordn/src/jvmTest/kotlin/com/vitorpamplona/cordn/interop/TsMlsFixtures.kt @@ -0,0 +1,112 @@ +/* + * 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.cordn.interop + +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** Reads the vendored ts-mls fixtures. See `resources/tsmls/README.md`. */ +@OptIn(ExperimentalEncodingApi::class) +object TsMlsFixtures { + private fun resource(name: String): ByteArray = + checkNotNull(TsMlsFixtures::class.java.getResourceAsStream("/tsmls/$name")) { + "missing ts-mls fixture '$name'" + }.use { it.readBytes() } + + fun bytes(name: String): ByteArray = resource(name) + + fun text(name: String): String = resource(name).decodeToString().trim() + + fun b64(name: String): ByteArray = Base64.decode(text(name)) + + fun hex(name: String): ByteArray = text(name).chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} + +/** + * ts-mls's `privateKeyPackageEncoder` layout: + * + * ``` + * opaque init_private_key || opaque hpke_private_key || opaque signature_private_key + * ``` + * + * The two implementations store the Ed25519 signing key differently and neither + * is wrong: ts-mls (noble/WebCrypto) writes a 48-byte PKCS#8 `PrivateKeyInfo` + * wrapping the 32-byte seed; Quartz keeps `seed || public`. The seed is the only + * thing either really holds, so that is what converts here, and the public half + * is re-derived rather than trusted — a pair that disagrees with itself cannot + * be built this way. + * + * Layout documented by Staircase (`cordn-core/…/KeyPackageBundleCodec.kt`, MIT); + * this is our own implementation of it. + */ +object TsMlsPrivateKeyPackage { + /** RFC 8410 PKCS#8 PrivateKeyInfo prefix for Ed25519; the 32-byte seed follows. */ + private val PKCS8_ED25519_PREFIX = + "302e020100300506032b657004220420".chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private const val SEED_SIZE = 32 + + /** Accepts a bare seed, ts-mls's PKCS#8 blob, or Quartz's `seed || public`. */ + fun seedOf(privateKey: ByteArray): ByteArray = + when (privateKey.size) { + SEED_SIZE -> privateKey + 48 -> { + require(privateKey.copyOfRange(0, 16).contentEquals(PKCS8_ED25519_PREFIX)) { + "unexpected PKCS#8 Ed25519 header" + } + privateKey.copyOfRange(16, 48) + } + 64 -> privateKey.copyOfRange(0, SEED_SIZE) + else -> throw IllegalArgumentException("unsupported Ed25519 private key length ${privateKey.size}") + } + + fun decode( + keyPackageBytes: ByteArray, + privateBytes: ByteArray, + ): KeyPackageBundle { + val reader = TlsReader(privateBytes) + val initKey = reader.readOpaqueVarInt() + val encryptionKey = reader.readOpaqueVarInt() + val signingSeed = seedOf(reader.readOpaqueVarInt()) + require(!reader.hasRemaining) { "trailing bytes in ts-mls private key package" } + + return KeyPackageBundle( + keyPackage = MlsKeyPackage.decodeTls(TlsReader(keyPackageBytes)), + initPrivateKey = initKey, + encryptionPrivateKey = encryptionKey, + signaturePrivateKey = Ed25519.keyPairFromSeed(signingSeed).privateKey, + ) + } + + /** The inverse, so a round trip can be asserted. */ + fun encode(bundle: KeyPackageBundle): ByteArray { + val writer = TlsWriter() + writer.putOpaqueVarInt(bundle.initPrivateKey) + writer.putOpaqueVarInt(bundle.encryptionPrivateKey) + writer.putOpaqueVarInt(PKCS8_ED25519_PREFIX + seedOf(bundle.signaturePrivateKey)) + return writer.toByteArray() + } +} diff --git a/cordn/src/jvmTest/resources/tsmls/README.md b/cordn/src/jvmTest/resources/tsmls/README.md new file mode 100644 index 0000000000..adb9ff0c4e --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/README.md @@ -0,0 +1,35 @@ +# ts-mls interop fixtures + +Generated by **ts-mls** (the reference MLS implementation cordn's own client +uses), not by us. They are the cross-implementation half of +`spec00Coordinator`/`spec03Payloads` testing: everything else in this module +proves we agree with ourselves. + +Copied from **Staircase** — , +`conformance/fixtures/gen/`, commit `d9dd1a0` — which is MIT licensed +(Copyright (c) 2026 relay.tools). The generator that produced them is +`conformance/fixtures-gen/gen.ts` in that repository. + +Only the files our tests read are vendored; the originals include multi-device, +media and three-member fixtures we have no use for yet. + +## The lifecycle these describe + +Alice (`aa…aa`) creates a group with metadata, adds Bob (`bb…bb`), and sends an +application message. `gid` is the delivery id. + +| File | What | +| ---- | ---- | +| `alice.pk`, `bob.pk`, `gid` | the actors and the delivery group id | +| `bob-kp.bin`, `bob-privkp.bin`, `bob-kpref.hex` | Bob's KeyPackage, its private half in ts-mls's `privateKeyPackageEncoder` layout, and its RFC 9420 KeyPackageRef | +| `bob-lastresort-kp.bin`, `bob-lastresort-kpref.hex` | the same marked last-resort, via `app_data_dictionary` component `0x0004` | +| `meta-1.json` | the `cordn_group_metadata` Alice created the group with | +| `exporter-e0.hex` | `MLS-Exporter("cordn","group-payload",32)` at epoch 0 | +| `commit-add.b64`, `commit-add-sealed.b64` | the Add commit, raw and sealed under the PRE-commit epoch key | +| `welcome.b64` | the Welcome that admits Bob | +| `exporter-e1.hex` | the same exporter at epoch 1 | +| `app-1.b64`, `app-1-sealed.b64`, `envelope-1.json` | Alice's application message: MLS bytes, sealed form, and the envelope inside it | + +Note `app-1` carries `authenticated_data` = UTF-8 of Alice's pubkey. cordn +rejects an application message without it, and the spec never mentions it — see +`spec02Envelopes/CordnApplicationMessage`. diff --git a/cordn/src/jvmTest/resources/tsmls/alice.pk b/cordn/src/jvmTest/resources/tsmls/alice.pk new file mode 100644 index 0000000000..71b7a71962 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/alice.pk @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/app-1-sealed.b64 b/cordn/src/jvmTest/resources/tsmls/app-1-sealed.b64 new file mode 100644 index 0000000000..854339faff --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/app-1-sealed.b64 @@ -0,0 +1 @@ +Vx7fA90kNPdkQTos2EUfMxTQyaxr31Z8Du+6xHDZW+EFSAIWBeWx7cYYk/o+f9hkX0WpRTIJAW18xlMSHZqaNJZTdKlKLA1BXo74LlmWkF2CklFSWzFCrVAZkdXLEFWv37CA/jqyx5vj92iECOFpPUjHupu/frnJs1SAsFnp+YVp8aejAf11BcaBIcJOOXhn8gy0vcK9BUSq5YEuSswWXFAdG/R8NmSV59f1+Juh0tr1/eJLeosiQH2jp/42k3yAfjRTQqAXxKOko/yqhmVyZzu/j/Mvu3xlVDEj8L4QrS8BshyYwmpRHCyQUNdCVGAkXcVcE5pUtqC7uqTrGZ2C0kA3AQvevSoFc4m+juTKzn/lYNrFmShNKQYuY2Ztv0XBXNnSHk46rOLcagB7tpHfBAZV12TDlfUBMuX1WgiI8vw5HTIuNHANIENo/ETNrTgT8mW6zM2WZBfoR3HJXfQWrmAtRsgsZ0mj1kwav+J8pMCYTRdnQeKNRuFq/NePSH3xRA7LREaQj3EafaYVSjP/cGDOOg3TFRdp8BSUrYqVN07FKyssDQkB/9ktCxUOWEkfZiI+1ScVa+XGdXpuzLK6mh1RIK900zLyDUbLCTh4Vab65eqV1qOJppKGWkW+KanMlRsNt68Y/eLFuAfzb/Ck8E3+ERqLXnH57NoH4e5C4QfG4i/s8cT1mgzZhD50Z/LnIAj+LYLpu0sMgeJiJXTbsQ== \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/app-1.b64 b/cordn/src/jvmTest/resources/tsmls/app-1.b64 new file mode 100644 index 0000000000..3e8a5b8716 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/app-1.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAQFAQGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEcClw/xgK9wHoxslXko2Mtf6lsYARPc+TLlYT9qEF6ofy1MNiHjW4JdFsUYHHvJlehr2R4Z03Urnxq9RZeUxQ3+NwuJQj83Uu3ooow9VBYbbqvW5nAngQOzmnbtIt+GxzfxQWRQyHIkiJpNaoKMVmmbjDhWMQ6ZrXZNxmKHFTU5IT+jqcaF1yzbR5a+mbftI9vTYKrQwzfcVduVUFoUePKFb+4W6TR7AGM8FBxidJPaS0l+Q7WHaK5pinNi+q1bpk5O5TigivuypkJMsLBid3mbcYylQf7Vhz0UGSlHJX0qFvsZIflInpBdUWqbSZVcjIu6AQNqyE0mu1kIuZD229x4687PwxN0wEZkBmQ9k02SzwyBtFwnPp4scQpqiV7cZSMMfjA85Hm2MngZJZmftIGq66GPXPm9WWD/3Jfo/oh6Q4/Y2QoIvVkDC/uo60dudwsDcFnOgJ10XM3w50eyfbpLLxdd9GKtOYsmK6dGxiZl46tZ4fPSh+PLemtnleSP8Bdh4+jyfwmP0mt5p8amyY0fSZPOC2iwP2l \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/app-2-sealed.b64 b/cordn/src/jvmTest/resources/tsmls/app-2-sealed.b64 new file mode 100644 index 0000000000..789bba0abc --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/app-2-sealed.b64 @@ -0,0 +1 @@ +vflv1/yY10H3C5/V8XD78iY30V4UCV1RvwGyDLdCHGvtpMDlUYfD4vhs6q1X9bAZP/Xi0+82AS1Jv9rwOAB0qvbsZI1PPM20mK8qGk3SXrBMDd0ZephmAFTuDAcw0ioSd67O1FUepBG/5uyMr/Zk9NCS87RCcXGtY0M0OzcDz34ERrlUwlxMM9d2gPMgZAs8b1FuQf4uaKbuTE9XYYqaSXRbPDDTZ5RX7LFMPo9hqxbkWd/J/CPGvx0nzd4E/e3TjD5GdySW9huEXWvSQ9ietfHtj2UB/my9JP3JhJtUtewTCyTHLGdcYUZJwjDukPPFVp1v8snYKeis1r021ZvQmwQCeW6zKxGnT1qqbCWfsN8dibF8dRP/iOkML6gkFq9TFohAjExq8AVhde5uBy7OUtudcWD+Pl7wEVbwUpbLu166KoiIIAOEIJ6cRV87dvjMYPP5t50bnTbf4P0BPHBeDcQ1wnJ2x+id6qzEREXe1B3hDOIDky5JGUyLzA7u8qeR0hPLZle0B+uhJaM6pqgNBHl1ZzPL1ifSa6ZK/kJTOH/nk8agM06RsAR42scieP5XTbIhxC/mjFyAvWxvCO0Sd05qPiVRL6xg82KGPs7pXfNcRsrziLpLRN229RxiPCt3x6JgjA9KLFN65jNnofcEtp/OR1lvimtd/HW6qb5vxpXayAUbO1TyPIyj/0GlmQUHs4NIGCTD0vTdgXHGtuE4ZDqXG+Xd+rtl25Q7JdYmM/wfyr6sq7Iv4s7kt7tXjaUwkWCodxVbFHTmbdAQAujOXFaymOcJdhXapZgUmp0hqI+NacgXeBbtEe+gB90elgSCMRJp3NUp \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/app-2.b64 b/cordn/src/jvmTest/resources/tsmls/app-2.b64 new file mode 100644 index 0000000000..28081d449e --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/app-2.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAgFAQGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEc9O2FAe3ZQqYftiKbAFK9k30vIRV5VdnsMbkGC0HQBhwc/60MQyixbwotWEqvz8i8J/Go608Y61remWpuyl6zbxQETHEnkLcMTJJuJSFZZyKQXFbE+Rnsj1JdBTB7P9PjtOgpTbsyvPO31D2MeMymblzXkjMMw/3hUXZVCoDKiWmIuS6AahAS1vmkEzvJjaTItFP/pkTIk3FmcREouSajlhio3L70BC02clU6sGcu68Kb3OulfviPDc0AwLuvD2sOfLctYI6CHQQs9eFHafA48yalsMfR0lZyFvrIpr8KNurzOW7qcDo0iqfnuHy5vFPlPwn3qKB/XxP30e+whdog060TgYdbvMCeWXEMYdPOuOgpcSXaTMZnXeqZZTyh/PPLWAbzmFrRsfQoRWmktBikW3/nZyE6j3c8FGywrMiq7/kE8AXkC70Wh2nnLwA3PTbzvqo3uVXq3J06YNhi+DUCgVNoEZfcK5fpTCDoo7CLBJFofhXrrccM4pPCpm8wkpEvyeLi97nKOhsBnAIjTif8zdrrN0yhZVvlePuCUhFRDtVbcN9hR+6R/3sXHDMZ0rvDlpG2QHeZ/OtXOouLUngOE1L9h+q5iuYAsI7cZeXmdiv2+4Ut2I4X8038bWrNwbjffD2vpVQCYMuIgkk9FEQ= \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/bob-kp.bin b/cordn/src/jvmTest/resources/tsmls/bob-kp.bin new file mode 100644 index 0000000000..79106a1ee4 Binary files /dev/null and b/cordn/src/jvmTest/resources/tsmls/bob-kp.bin differ diff --git a/cordn/src/jvmTest/resources/tsmls/bob-kpref.hex b/cordn/src/jvmTest/resources/tsmls/bob-kpref.hex new file mode 100644 index 0000000000..49ff3ea2c3 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/bob-kpref.hex @@ -0,0 +1 @@ +946816156873cffa538f63644a2d44624d48e8fbb6b0958c58531d80f0e75f33 \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/bob-lastresort-kp.bin b/cordn/src/jvmTest/resources/tsmls/bob-lastresort-kp.bin new file mode 100644 index 0000000000..2d125d7aac Binary files /dev/null and b/cordn/src/jvmTest/resources/tsmls/bob-lastresort-kp.bin differ diff --git a/cordn/src/jvmTest/resources/tsmls/bob-lastresort-kpref.hex b/cordn/src/jvmTest/resources/tsmls/bob-lastresort-kpref.hex new file mode 100644 index 0000000000..17bcb50589 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/bob-lastresort-kpref.hex @@ -0,0 +1 @@ +a8b705cda5b133634d31f4e2f4438186b25b91d23d159600663ba2560fc58ffd \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/bob-privkp.bin b/cordn/src/jvmTest/resources/tsmls/bob-privkp.bin new file mode 100644 index 0000000000..962baaba15 Binary files /dev/null and b/cordn/src/jvmTest/resources/tsmls/bob-privkp.bin differ diff --git a/cordn/src/jvmTest/resources/tsmls/bob.pk b/cordn/src/jvmTest/resources/tsmls/bob.pk new file mode 100644 index 0000000000..0e71009335 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/bob.pk @@ -0,0 +1 @@ +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/bob2-kp.bin b/cordn/src/jvmTest/resources/tsmls/bob2-kp.bin new file mode 100644 index 0000000000..f38381c97e Binary files /dev/null and b/cordn/src/jvmTest/resources/tsmls/bob2-kp.bin differ diff --git a/cordn/src/jvmTest/resources/tsmls/bob2-kpref.hex b/cordn/src/jvmTest/resources/tsmls/bob2-kpref.hex new file mode 100644 index 0000000000..5054894b76 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/bob2-kpref.hex @@ -0,0 +1 @@ +c58c248465ee592c041b961981be8080bc6e772a5743138dbf3ccee8c3c0fcdb \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/commit-add-sealed.b64 b/cordn/src/jvmTest/resources/tsmls/commit-add-sealed.b64 new file mode 100644 index 0000000000..aeedfe4d25 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/commit-add-sealed.b64 @@ -0,0 +1 @@ +U2UAiQ2F5QLquDurFnYpGoSEH7koKkwAKyz6Bxyxr8UULVcaPJoBwr5jq6UYnUR7vR3RhmwkIrjYqd85r/lNljvcwyTzS0kWr3fBr3lM+8r8kB3pDzNlmKEm3wXR1nWIw52inGzTBfamyK/I6RtEjoK1FMCzt5/+h2ArjhHi1q58lbUI6SwPLtyYCYvpQRoP+Kb6uJdRdJyG33RZZ77J65ami45ANgaOWT5iegHEdnRPBCYLT8s1JNya/ejnV/ojBlQpMrjPPROuDsGYb8Cp18Q1msU3ASYMbtno8iYYkq1aMJU0O/sgKicXN0e+fDlMZfODfN2T7hzVrzhRR7ItuMNR85cjO+pHg+KW8invyD1L7QzFlALYlJPXiBY2bHmcGA/XVAPcnGpz/qV/EFm9gvuOol7sOnL+o4zwCyoC2SRNp/pe/9vH+48jI6YZ6uCkQY5j+AtsyOa2dDSfQ0vdUMKr+QgdDVaOloRXp+AR2AG5HS98pq32XUfCevZCV3Nru8bhnzH491YunbFjIiSJ/uCxTsYfjkquRXoKpwJ4mdV1wumbyRWmJIVQvdLPE+neP2HhM0k6CNcZKovfUBpGvBl7/0Mi2ax1qFfc6e1uvulAtUNDPDwzh0VKpioIgDjvGkFjmVawKbuEAzHfD19WJq4fe6YEKbcB8wfPN5sOu58UB7UamZ/B7RS3hnXeCog+DpRli9dtaVxmHCKp4G+eKOV3yRHTrHOyMqxx1lrCB8hVxRi38q+hrLT3IjIrU8V2mNjHF1ONLyFeQHjcBPlCycIs+MmWp0401I5dFntaTw== \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/commit-add.b64 b/cordn/src/jvmTest/resources/tsmls/commit-add.b64 new file mode 100644 index 0000000000..cfc348f2ed --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/commit-add.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAAMAHBR1JYMyulPydTNkijeWsL5bfIOcs3Lj3Svtb4lB+v/Sc3pqj2WxtHZgaVlZ+0gztUw+NqETsgSCLtUH1u6Twez81oull+LvM9JQdXacjSWqa0KhiNPU/AgCcwwLV29UriXPhPOgRlY713EZqwlewxgS2UB6I80XISbpFNHuiMFZiXCqraYewU7UhYsyjgWsax91Mc9ARHub9pI5NIICy/oiOEibO3RQfF1PZitbaMjWwDqmWmnd/Im7ZmAglL/HjYuFawYqiNlErPlC5WuudirWsaS9Kd02JWZicFjMaFtaMAZh3VSt2VQ3tmBIPTeoHtCL+728frA+Q60fw2s5MjU8vvfNTbXnRrMHYB+UTCHAFvTHFSI/1Pv/6o5y690AIkDWKmzdZUjAloK2mrcXgWAJuQEYODgTPtHTLiAzVC5vwoewqOjJv0yF0/WQv3XvzuIFlKev43uTmFWU281ZSu15hUaSEb4lx2zsph2cfxpMAKGq0N+uImcht16UoooN1aZ9DkwxcPVQKGQeHM/sw841i9AqDsspq9UIhWWCLFWmh0R1zY2AL/PZRy52N/vLZSo5+UocA9CgqOWuKPht0O4hO9sKYQD3hhdOeEiJffHO7N9UJgm3zruZ3BYZqyHA8uF8+UaKCA03VN4oGWFA+dZOgU/Yq9pJjQr8e3BrPkOSRlOtz7/pWMEBzUPHX7h02sIykjtNLJFW \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/commit-meta-sealed.b64 b/cordn/src/jvmTest/resources/tsmls/commit-meta-sealed.b64 new file mode 100644 index 0000000000..0d1f6a4eb8 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/commit-meta-sealed.b64 @@ -0,0 +1 @@ +VsgX7mX1z0ujSl2MOaQ58uj3BZ3UcMiG5gp0EGi1/6HynI8+BUU2WLTH7+La4DEHsX6M8JdKY9OVC9ComRQSU4p21G0Czfg6uo6kGYuBukUn9QUFwcBTr6sPXQJSNWwRcFuLKtqc+I+3OOla74xDnZhq4UptkML15/0CJdH3XWJDqzmjVDK0XFVU8D4jqrPhE06Mrfgz5Hp4kmSdlFQufT0L2s+lF1NBz84P4z1/tQe7CiLnf6b0a5Gw5erraW/P7bhMUN2hRm4TVsD9haz2BL1bqPuF+YgyoMa7FqZPlLYSIbqFjmBx11jTV/W3HFp29G8pt57HqBD5pZdNqQJT1mu4oNJjpP2tV9LjZ5i5K9ic1SufSTMk79WC3Hzxa0+iqABVtJC832qZmoFlFQK5kho+oHjRgbrUaKTml2kMVs1KockNBmbfiZNIDj59XPf759uvrLW2vrYDOoR57qeKSmAiZ/vXyaNgr9lwJpO7ORnXlf6n2/PwX0I4j9z9BxbtqqTjeRPObyrcEDhhc1NCwdvseabIy8FCnrxhfz1CeUjVZBNELEdcWojKEg7a/873lZNvksEk6rfnreZ1C/EbzDW2L+vtMo53oYwgIbhrPZsa4Miom1SnZ/LcYHDWlGE7SZFxotceJFsTlk8ahHz8JJhViK/RbGJFxuoGxtRfCRWfcacDc9Ea7LrvM98jugPnKei0SEMRIfqfvdaBu4NKYA6M0SB5KMh6dP+HdGWH6g9Ip0fX8xIiv6qeiku4lkcDxIg3rSg+/IbNwU6w4+ZWQHxEXSGTFCQqgIv2yHiMg1ZYXGjpdn9wOlLtxlpM3Jtlg38Lur/zPESCTCVEjrhsTdj+jAQegV0PQc5jH4AskPD4w0aQ54/RlulxLjqb+vy1XBM+yWuSFss0dned1Qz9rFotPQwBzG8J4nNeJcPcsVt2F2KWpU20a57m6PtVv7d6kYaEgXu/oxQhQBS1sNKaUeDE9bIcBM1yBl1DSreUwn2acQ== \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/commit-meta.b64 b/cordn/src/jvmTest/resources/tsmls/commit-meta.b64 new file mode 100644 index 0000000000..5e811bf91b --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/commit-meta.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAQMAHGwxwtIyuyJLoVfk7uV6IjH3qlUnETdM12IZcrpCjTUNnvdEiR7tRiOQs0hv08jwpI6mw3uJ3aXEpUeQcAvs/Wunm1NuZX4itKRRT/eUfxicX1itiDHu0yknHCxwDQT2Z7blCF+Y5TPLk7bvK6Wui7bQ3gtbr7G0OYnc7yX6qez37sXOHCXK2FVoffuF71OWZj68hv3f4VFRKpQiynDL3ZJ4KSAEOyFjZsDKAPyzaXxw4fRhvGd0zn4PahKVqQqC88LNgLRmhsdrpa0aCB/F0NUYR3ezkmGTuCXHETxvKjnkE+3DM/s9GydkxHSfmYwjcqXAQF+obYnm+5gwTmzqu68o0q2irCq3pSp5VfevEmKanNgvujmLgWocJ+DO0ONSjWGCGOYfQXWOGKI1kVHmw6AneHNhVKkdBCPnCxMOEP6TYi6NBWCLld48lFp4HNDr4TICkFTNjJOHkmWLVOP4Bt8yTS4le98AcT9ybNPaHx+LqyKUXbr2MDogGCRJhiycZ3AN/xJLZH77XmO4CoFCzlTxUqej2T77fEbpVNW2LAPYPj0OsgVJICaKf3s1rYOvu6nB0gKsBhA5mJE1mPR5S2NPapzHmIG8vLKK9v8WkzW+M6BdpX/RysD08Cupl8cF7V1EM+zPWuSWlE24IfvYMeS358UuwU7lsM0st9keis+7flK1j6xpaAjven70JMUmPdfjcV23BiyFJ1pM+srhj+QKzCLSqEwSU00RXusZ/+hgbDrEd/LFnG1wtjdaLiQwpGgreHvqDczpvAkC+r2H61cZQ4ysXSjJ6z7TqyfTZiy90UNA07Zy7GShj0Xc/TNI1N5rbffPynOJ+qhD1FN/3CV2F6fXu8oY/mp4660mmT3xdmOjtr7XsSFII/LZGTN/apxiseZ49UPZ0Le3 \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/envelope-1.json b/cordn/src/jvmTest/resources/tsmls/envelope-1.json new file mode 100644 index 0000000000..95156d7ee3 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/envelope-1.json @@ -0,0 +1 @@ +{"pubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","created_at":1700000000,"kind":9,"tags":[["p","bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]],"content":"hello from ts-mls","id":"92726892b7665f0c2bf53733e52f50c01f202a756696e58eb84e64177ce72601"} \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/envelope-2.json b/cordn/src/jvmTest/resources/tsmls/envelope-2.json new file mode 100644 index 0000000000..1ac321a1c4 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/envelope-2.json @@ -0,0 +1 @@ +{"pubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","created_at":1700000002,"kind":1111,"tags":[["e","92726892b7665f0c2bf53733e52f50c01f202a756696e58eb84e64177ce72601","","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],["k","9"]],"content":"reply at epoch 2 ✨","id":"e73aa0bc24b3f5fcb02547b00045d006859427888cc8f549048b1fd6ddf12234"} \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/exporter-e0.hex b/cordn/src/jvmTest/resources/tsmls/exporter-e0.hex new file mode 100644 index 0000000000..d245a85273 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/exporter-e0.hex @@ -0,0 +1 @@ +141ec831ebd0c989ad977e6dd9b28eeaee8a608ff7fe137d52f571aa4045b680 \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/exporter-e1.hex b/cordn/src/jvmTest/resources/tsmls/exporter-e1.hex new file mode 100644 index 0000000000..8888211f9a --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/exporter-e1.hex @@ -0,0 +1 @@ +b4d177e140624c206b3e1890138b17cc2a2f889c3569faf3ccea77e6d6ed3d31 \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/exporter-e2.hex b/cordn/src/jvmTest/resources/tsmls/exporter-e2.hex new file mode 100644 index 0000000000..95b5d76f8e --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/exporter-e2.hex @@ -0,0 +1 @@ +b0ba537f4b2256e7c3473fbd078ebbc0362a81b0b5a0bcd0572e5b8ea0e7a593 \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/gid b/cordn/src/jvmTest/resources/tsmls/gid new file mode 100644 index 0000000000..41e91eb013 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/gid @@ -0,0 +1 @@ +staircase-conformance-gid-1 \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/meta-1.json b/cordn/src/jvmTest/resources/tsmls/meta-1.json new file mode 100644 index 0000000000..3f2d3189b3 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/meta-1.json @@ -0,0 +1 @@ +{"name":"Conformance","description":"ts-mls ↔ staircase","adminPubkeys":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],"icon":"🪜"} \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/meta-2.json b/cordn/src/jvmTest/resources/tsmls/meta-2.json new file mode 100644 index 0000000000..aa36fd56a7 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/meta-2.json @@ -0,0 +1 @@ +{"name":"Conformance (renamed)","description":"updated by ts-mls","adminPubkeys":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],"icon":"🪜","imageUrl":"https://example.invalid/g.png"} \ No newline at end of file diff --git a/cordn/src/jvmTest/resources/tsmls/welcome.b64 b/cordn/src/jvmTest/resources/tsmls/welcome.b64 new file mode 100644 index 0000000000..23adf90bc0 --- /dev/null +++ b/cordn/src/jvmTest/resources/tsmls/welcome.b64 @@ -0,0 +1 @@ +AAEAAwABQHYglGgWFWhzz/pTj2NkSi1EYk1I6Pu2sJWMWFMdgPDnXzMgwoylwN0z/xCGTlObRBCnAtpCHmMJQmrrOOp2fXqpzD4zKK8OcMS/7X6046dq4S6jkvlqTOav50y1xZN8yI8iAbhQC99j+HDTzv2iCrTITCLyBEhcQ2+/u3EYUtX2UrD2qzWKM9quqjGiO5azt3hX7IFJ83O/xBdze3rewaEfoS9NuRaO1tyJtS39QG+HMGyU/V+DtyUWNdGlinmEcT35JljIip3wnwyxvf41bWTleS6Xk5qfhfAeHvmYFmHowKRwyd5rsnAHs+Lb1rOqWA1CmtSAYi0Kh0aXQSmsHiFPZvZhHV+y8kXAWwtqr58LWebQSPv6iwEb6wbgXKic6PSSrSdtbv1XgjjPVHdA696J5cxFsQpPL8/cX+UZJZVg98mLibBxbyNZDxDPWXB2im3cgpn+UoqoOaxlx0RfFZkOEaRPd+48QsITaRrbw4vs2Hoq6cQI1sUlMa8wwbNMtG4Ybm7foMaGaZmHG8REMfzcbi3NyolGmxBWEaNbrLXclrAqcH2gojj0B8jXiYGsA5D4moEiqPybwUdxdPD/DYzp0rvAbw6bWpWmin4c9r2Vmvvt5bhPd8cn+hmQchKvscAh0mf5GXTwa/rsypesJtF+8xo0st4bdUptfkJzbaadc6h4h31a+5/798GghhGkcteE0hPHzEHXAwnEaN8/yyMPRK6JRmqcLPqKNz4oPybPezu3+yi4DSlLYmStIynd/6jJKRoc/b3Yvjs2FTdT2hJgHtb1taOq4Tq8TxXaeILAIc6WX/bFnYc8IIb6XMMOoY7HBakN7sI5v18a7M1hmOSeycz62s3z1zn+gDfhHgh6Kpi4BqNFNTufKMJ3IF/Tm32I01Ao43ISKlqroQaL2GCZfLZE0aeYgKZT4HZhVS2KPN1CJ2H7bQC2fccoD6VbfXeXU2OP03bmjuY/JQdV6idY1CZeZ0y6ZkUxg2ab0FvRosarlsjeWt/JR5zuiIgqQmPWcjtu8fu2LjYgUO0yeN+/CgqfKWMXEemZXJMUUkx5ywF8qx5LAKNGZZBLpM2vDV1Ia3BPQhrzjGI3z5jTPW7SHZ2m0FSHMSEmPZg/wpELbBl8D/72qA6QyKs+15rChIITvPd/Cr7HgjvYfTO3wLYSn101r3H7eurqQJ6akChbfAOIA0d7/6eQNf0l5HR8M/3dwS9UwoH+6KguLCNMNfZDH4vCj+qTljS0Bff2H9Aq6OmUZZXQF8b+VQJuUAS8WLyVPx50SGEFiGqeymYgbcOnCXQ3ARbJjN6JkHnnehbNZhgpOciOQiE= \ No newline at end of file diff --git a/quartz/plans/2026-09-17-cordn-interop.md b/quartz/plans/2026-09-17-cordn-interop.md index 64ce1df939..97019193a6 100644 --- a/quartz/plans/2026-09-17-cordn-interop.md +++ b/quartz/plans/2026-09-17-cordn-interop.md @@ -852,10 +852,64 @@ messages, each kill their guarding tests at both the unit and end-to-end level. those needed a new end-to-end test — a single catch-up pass looks correct either way, and only a second pass reveals the stall. +**Cross-implementation verification — added after reading +[Staircase](https://code.relay.tools/opensauce/staircase) (`d9dd1a0`, MIT), an +independent Kotlin cordn client that vendors this project's own MLS engine.** + +`cordn/interop/CordnLifecycleInteropTest` walks the whole lifecycle against +fixtures **ts-mls** generated (Staircase's `conformance/fixtures/gen`, vendored +under `cordn/src/jvmTest/resources/tsmls/`): read their KeyPackage and agree on +its `kp_ref`, unseal their commit under the published epoch exporter, join from +their Welcome, derive the same epoch exporter byte for byte, apply their +`0xC04D` metadata commit, and read their application message end to end. 15 +tests, all green. + +Reading Staircase found three things no amount of self-consistent testing would +have: + +1. **`authenticated_data` is a wire requirement the cordn spec never mentions.** + The reference client puts the sender's account pubkey in MLS + `authenticated_data` and **rejects** any application message that arrives + with it empty (`packages/cli/src/groupSync.ts:247`). Our engine AEAD-bound + the field correctly on receive but hardcoded `ByteArray(0)` on send and never + exposed it — so we would have shipped a client every cordn peer silently + discarded. `MlsGroup.encrypt` now takes it and `DecryptedMessage` carries it; + `spec02Envelopes/CordnApplicationMessage` owns the binding. Worth raising + upstream: it belongs in `spec/02.md` §5. +2. **Our GroupContextExtensions check implemented a rule RFC 9420 does not + have, and omitted the one it does.** §12.1.7 says nothing about recognising + extension types; its only validity rule is that the resulting group must not + require capabilities some member lacks. We rejected any type outside a + hardcoded list — which would have refused cordn's `0xC04D` metadata commit + outright — while never checking the real rule, so a commit could install a + `required_capabilities` a sitting member could not meet and split the group. + Both fixed; `MlsGroupPolicy.knownExtensionTypes` is gone, because it encoded + the invented rule. +3. **`Ed25519` could not rebuild a key pair from a known seed.** `keyPairFromSeed` + is now in the expect/actual set — any interop fixture needs it, and ts-mls + stores only the seed (inside a PKCS#8 blob). + +Staircase also independently confirms Stage 1's design. Their `VENDORED.md` +lists the same decoupling we did — remove `currentMarmotData`/`currentGroupState`/ +`currentNostrGroupId`/`agentTextStreamSecret`, drop the `AdminPolicyV1` branch, +inject the admin resolver, do not vendor `MlsGroupManager` or +`MarmotMessageStore` — arrived at independently, as patches against a fork. +**Now that the seam is upstream they could stop forking**, and their remaining +patches are a ready-made list of what a cordn binding still wants from the +engine: caller-chosen `group_id`, explicit leaf lifetimes (cordn uses ~100 +years), retained per-epoch receiver data, and skipped-generation keys. + +One trap worth recording: `MlsGroup.memberIdentityHex` hex-encodes the +credential bytes, which is right only for a binding that stores a raw key. +cordn's identity is already hex, so it returns 128 characters of hex-of-hex; +`CordnCredential.memberIdentities` is the cordn-side accessor. + Still open in Stage 3: -- **A full group lifecycle test** — create, add a member via Welcome, exchange a sealed - envelope. Every piece is tested; the end-to-end path wants the Stage 0 vectors. +- **The reverse direction under their client.** We add a real ts-mls KeyPackage + to a group we created and produce a Welcome, but nothing here runs ts-mls to + confirm it joins. Staircase's conformance suite writes Kotlin-side fixtures + for that exchange; wiring both halves is the remaining step. - **Tier B**, live against `ghcr.io/cordn-msg/cordn:latest`. ### Stage 4 — App integration diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.apple.kt index 209f346438..5f6f1c6e31 100644 --- a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.apple.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.apple.kt @@ -128,6 +128,12 @@ actual object Ed25519 { return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2) } + actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { + require(seed.size == SEED_LENGTH) { "Ed25519 seed must be $SEED_LENGTH bytes, was ${seed.size}" } + val publicKey = derivePublicKey(seed) + return Ed25519KeyPair(seed + publicKey, publicKey) + } + // --- Internal operations --- /** Derive Ed25519 public key from 32-byte seed. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt index f7e758771c..b382b5f22c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt @@ -55,12 +55,6 @@ object MarmotGroupPolicy : MlsGroupPolicy { override val defaultRequiredCapabilities: Extension get() = MarmotCapabilities.mipRequired() - /** - * `0xF2EE`. The current profile's `app_data_dictionary` carrier is already - * in the engine's own set — it is a draft MLS extension, not a Marmot one. - */ - override val knownExtensionTypes: Set get() = setOf(MarmotCapabilities.MARMOT_GROUP_DATA_EXTENSION_TYPE) - /** * `MLS-Exporter("marmot", "group-event", 32)` — the outer * ChaCha20-Poly1305 key for a kind:445 GroupEvent. A commit must be sealed diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.kt index 6a40d19472..08a468fbdd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.kt @@ -64,6 +64,20 @@ expect object Ed25519 { * @return 32-byte public key */ fun publicFromPrivate(privateKey: ByteArray): ByteArray + + /** + * Rebuilds a key pair from its 32-byte seed. + * + * [generateKeyPair] makes a fresh random one, which is right for a real + * client and useless for anything that has to reproduce a specific key: + * an interop fixture generated by another implementation, a test vector, or + * a private key stored in someone else's layout (ts-mls keeps the seed in a + * PKCS#8 blob, so the seed is all you get back out). + * + * @param seed exactly 32 bytes. The public half is derived, never supplied, + * so a mismatched pair cannot be constructed here. + */ + fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair } data class Ed25519KeyPair( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt index 01a3243586..aac9a70b69 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt @@ -226,7 +226,20 @@ class MlsGroup private constructor( /** Raw BasicCredential identity bytes of the member at the given leaf, or null. */ fun memberIdentity(leafIndex: Int): ByteArray? = (tree.getLeaf(leafIndex)?.credential as? Credential.Basic)?.identity - /** Lowercase hex of the member's BasicCredential identity, or null. */ + /** + * Lowercase hex OF THE CREDENTIAL BYTES at [leafIndex], or null. + * + * This hex-encodes whatever the credential holds, which is the account key + * only for a binding that stores it as raw bytes — Marmot does. A binding + * that stores an already-encoded identity gets the hex of that encoding: + * cordn writes 64 ASCII characters of hex, so this returns 128 characters + * of nothing useful. Such a binding should read the credential itself + * (`CordnCredential.identityOrNull`) rather than call this. + * + * Kept as-is because it is what Marmot means everywhere it is used, and + * because a function that guessed which encoding a credential used would + * be worse than one that says plainly what it does. + */ fun memberIdentityHex(leafIndex: Int): String? = memberIdentity(leafIndex)?.toHexKey() /** Lowercase hex of the local member's BasicCredential identity, or null. */ @@ -646,6 +659,7 @@ class MlsGroup private constructor( val leafIndex = applyProposalAdd(p) addedMembers.add(leafIndex to p.keyPackage) } + enforceRequiredCapabilities() // Generate new path secrets on the updated tree val leafSecret = MlsCryptoProvider.randomBytes(MlsCryptoProvider.HASH_OUTPUT_LENGTH) @@ -978,7 +992,25 @@ class MlsGroup private constructor( * The signature is computed with `SignWithLabel(., "FramedContentTBS", * FramedContentTBS)` using the member's signature private key. */ - fun encrypt(plaintext: ByteArray): ByteArray { + fun encrypt( + plaintext: ByteArray, + /** + * MLS `authenticated_data`: authenticated but NOT encrypted. + * + * The AEAD covers it, so a recipient knows the sender wrote it and the + * delivery service cannot alter it — but the delivery service can read + * it, which is the whole trade. RFC 9420 §6 leaves the contents to the + * application. + * + * cordn puts the sender's account pubkey here and rejects any + * application message that arrives with this field empty, because that + * is what binds an unsigned envelope to an MLS sender. Marmot leaves it + * empty and carries the same binding elsewhere. Empty is the default + * because a binding that does not use the field should not be paying a + * metadata cost for it. + */ + authenticatedData: ByteArray = ByteArray(0), + ): ByteArray { // Trim sentKeys if it grows too large if (sentKeys.size > MAX_SENT_KEYS) { val sortedKeys = sentKeys.keys.sorted() @@ -1007,7 +1039,7 @@ class MlsGroup private constructor( groupId = groupId, epoch = epoch, senderLeafIndex = myLeafIndex, - authenticatedData = ByteArray(0), + authenticatedData = authenticatedData, applicationData = plaintext, groupContext = groupContext, ), @@ -1023,7 +1055,7 @@ class MlsGroup private constructor( val pmcPlaintext = pmcWriter.toByteArray() // Build PrivateContentAAD (RFC 9420 §6.3.2) - val contentAad = buildPrivateContentAAD(groupId, epoch, ContentType.APPLICATION, ByteArray(0)) + val contentAad = buildPrivateContentAAD(groupId, epoch, ContentType.APPLICATION, authenticatedData) val ciphertext = MlsCryptoProvider.aeadEncrypt(kng.key, guardedNonce, contentAad, pmcPlaintext) // Build sender data plaintext: leaf_index || generation || reuse_guard @@ -1061,7 +1093,7 @@ class MlsGroup private constructor( groupId = groupId, epoch = epoch, contentType = ContentType.APPLICATION, - authenticatedData = ByteArray(0), + authenticatedData = authenticatedData, encryptedSenderData = encryptedSenderData, ciphertext = ciphertext, ) @@ -1303,6 +1335,7 @@ class MlsGroup private constructor( contentType = privMsg.contentType, content = applicationData, epoch = privMsg.epoch, + authenticatedData = privMsg.authenticatedData, ) } @@ -1718,6 +1751,7 @@ class MlsGroup private constructor( for ((add, _) in referenceAddSenders) { newLeavesInCommit.add(applyProposalAdd(add)) } + enforceRequiredCapabilities() // If the proposals just removed *us*, there is no path-decrypt to do // and no confirmation_tag to verify against our (now bogus) commit @@ -2586,6 +2620,29 @@ class MlsGroup private constructor( return tree.addLeaf(leafNode) } + /** + * RFC 9420 §12.1.7: a commit is invalid if it leaves the group with a + * `required_capabilities` extension some member does not satisfy. + * + * Run AFTER every proposal in the commit has been applied, which is what + * makes the spec's parenthetical fall out for free: the tree already + * includes members added in this commit and excludes members removed by + * it, so iterating the current leaves is exactly the right set. + * + * The failure this prevents is a split group. A GroupContextExtensions + * proposal that raises the bar above what a sitting member advertises is + * rejected by every peer that checks and accepted by every peer that does + * not, and the two halves diverge at the next epoch with nothing pointing + * at the cause. + */ + private fun enforceRequiredCapabilities() { + val required = findRequiredCapabilities(groupContext.extensions) ?: return + for (i in 0 until tree.leafCount) { + val leaf = tree.getLeaf(i) ?: continue + requireCapabilitiesMeetRequirements(leaf.capabilities, required, "Member leaf $i") + } + } + private fun applyProposal( proposal: Proposal, senderLeafIndex: Int, @@ -2624,12 +2681,18 @@ class MlsGroup private constructor( } is Proposal.GroupContextExtensions -> { - // Validate extension types are supported (RFC 9420 Section 12.1.7) - for (ext in proposal.extensions) { - require(ext.extensionType in KNOWN_EXTENSION_TYPES || ext.extensionType in policy.knownExtensionTypes) { - "Unsupported extension type: ${ext.extensionType}" - } - } + // RFC 9420 §12.1.7: a wholesale replacement, not a merge. The + // proposal's only validity rule concerns `required_capabilities` + // and is checked in [enforceRequiredCapabilities] once every + // proposal in the commit has been applied -- the membership it + // must hold over is the post-commit one. + // + // Note there is deliberately no check that we recognise these + // extension types. This used to reject anything outside a + // hardcoded list, which is a rule RFC 9420 does not have: it + // made the engine refuse valid groups built on any extension we + // had not enumerated, and `required_capabilities` is how a group + // that genuinely needs an extension understood enforces it. groupContext = groupContext.copy(extensions = proposal.extensions) } @@ -3034,24 +3097,6 @@ class MlsGroup private constructor( */ private const val LIFETIME_SPAN_SECONDS = 84L * 24 * 60 * 60 - /** - * Extension types RFC 9420 and the drafts we implement define. - * - * A binding's own types come from [MlsGroupPolicy.knownExtensionTypes] - * and are unioned with this at the point of use. - */ - private val KNOWN_EXTENSION_TYPES = - setOf( - RATCHET_TREE_EXTENSION_TYPE, - REQUIRED_CAPABILITIES_EXTENSION_TYPE, - EXTERNAL_PUB_EXTENSION_TYPE, - EXTERNAL_SENDERS_EXTENSION_TYPE, - // The current profile's carrier for all app-owned group state. - // A group can arrive at one either by being created with it or - // by a GroupContextExtensions proposal that installs it. - AppDataDictionary.EXTENSION_TYPE, - ) - /** * Parsed view of the RFC 9420 §7.2 `required_capabilities` extension. * @@ -4183,19 +4228,30 @@ data class DecryptedMessage( val contentType: ContentType, val content: ByteArray, val epoch: Long, + /** + * MLS `authenticated_data` as the sender wrote it — authenticated by the + * AEAD, but readable by anyone who carried the message. + * + * Empty when the sender set none. A binding that puts meaning here (cordn + * binds the sender's account pubkey) must treat empty as a rejection rather + * than a default, or an attacker simply omits the field. + */ + val authenticatedData: ByteArray = ByteArray(0), ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is DecryptedMessage) return false return senderLeafIndex == other.senderLeafIndex && content.contentEquals(other.content) && - epoch == other.epoch + epoch == other.epoch && + authenticatedData.contentEquals(other.authenticatedData) } override fun hashCode(): Int { var result = senderLeafIndex result = 31 * result + content.contentHashCode() result = 31 * result + epoch.hashCode() + result = 31 * result + authenticatedData.contentHashCode() return result } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt index 175ac62297..2b48ef3c17 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt @@ -102,15 +102,6 @@ interface MlsGroupPolicy { */ val defaultRequiredCapabilities: Extension? get() = null - /** - * Extension types this application understands, beyond the RFC 9420 set. - * - * A GroupContextExtensions proposal naming a type outside the union of - * this and the engine's own set is rejected: accepting an extension we - * cannot evaluate would mean committing to a requirement we cannot check. - */ - val knownExtensionTypes: Set get() = emptySet() - /** * How this binding derives the pre-commit exporter secret a [CommitResult] * carries, or null if it seals nothing outside MLS. diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.jvmAndroid.kt index e20e76cc85..027d6d8a8d 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.jvmAndroid.kt @@ -114,6 +114,12 @@ actual object Ed25519 { return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2) } + actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { + require(seed.size == SEED_LENGTH) { "Ed25519 seed must be $SEED_LENGTH bytes, was ${seed.size}" } + val publicKey = derivePublicKey(seed) + return Ed25519KeyPair(seed + publicKey, publicKey) + } + private fun derivePublicKey(seed: ByteArray): ByteArray { val d = sha512(seed) d[0] = (d[0].toInt() and 248).toByte() diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsRuleTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsRuleTest.kt new file mode 100644 index 0000000000..d094eb85b6 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsRuleTest.kt @@ -0,0 +1,118 @@ +/* + * 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.mls.group + +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * RFC 9420 §12.1.7, which the engine used to get wrong in both directions. + * + * It rejected any GroupContextExtensions proposal carrying an extension type + * outside a hardcoded list — a rule the RFC does not have, and one that made + * a whole class of valid group un-joinable. Meanwhile the rule the RFC DOES + * state, that the resulting group must not require capabilities some member + * lacks, was not checked at all. + * + * Found while building the cordn binding: cordn's `0xC04D` metadata extension + * is exactly the kind of application extension the old check refused. + */ +class GroupContextExtensionsRuleTest { + private val alice = "alice".encodeToByteArray() + + /** An arbitrary application extension type, in the private-use range. */ + private val appExtension = 0xC04D + + private fun requiredCapabilities(extensions: List): Extension { + val writer = TlsWriter() + val exts = TlsWriter() + extensions.forEach { exts.putUint16(it) } + writer.putOpaqueVarInt(exts.toByteArray()) + writer.putOpaqueVarInt(ByteArray(0)) + val creds = TlsWriter() + creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) + writer.putOpaqueVarInt(creds.toByteArray()) + return Extension(MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray()) + } + + @Test + fun anUnknownExtensionTypeIsNotAReasonToRefuseACommit() { + // RFC 9420 §12.1.7 lists exactly one validity rule for this proposal, + // and "we recognise every type" is not it. An extension we do not + // understand is one we do not act on; a group that needs it understood + // says so with required_capabilities. + val group = MlsGroup.create(alice) + group.proposeGroupContextExtensions(listOf(Extension(appExtension, byteArrayOf(1, 2, 3)))) + group.commit() + + assertTrue( + group.extensions.any { it.extensionType == appExtension }, + "the extension must be installed, not rejected", + ) + } + + @Test + fun aRequiredCapabilityNoMemberAdvertisesIsRejected() { + // The rule the RFC actually states. Accepting this splits the group: + // every peer that checks refuses the commit, every peer that does not + // applies it, and the two halves diverge at the next epoch. + val group = MlsGroup.create(alice, capabilities = Capabilities()) + group.proposeGroupContextExtensions(listOf(requiredCapabilities(listOf(appExtension)))) + + val error = assertFailsWith { group.commit() } + assertTrue( + error.message?.contains("required_capabilities") == true, + "must fail on the capability rule specifically: got '${error.message}'", + ) + } + + @Test + fun aRequiredCapabilityEveryMemberAdvertisesIsAccepted() { + val group = MlsGroup.create(alice, capabilities = Capabilities(extensions = listOf(appExtension))) + val epochBefore = group.epoch + + group.proposeGroupContextExtensions(listOf(requiredCapabilities(listOf(appExtension)))) + group.commit() + + assertEquals(epochBefore + 1, group.epoch) + } + + @Test + fun theReplacementIsWholesaleNotAMerge() { + // §12.1.7: "This is a wholesale replacement, not a merge. An extension + // is only carried over if the sender of the proposal includes it." + val group = MlsGroup.create(alice) + group.proposeGroupContextExtensions(listOf(Extension(appExtension, byteArrayOf(1)))) + group.commit() + + group.proposeGroupContextExtensions(listOf(Extension(0xC04E, byteArrayOf(2)))) + group.commit() + + assertTrue(group.extensions.none { it.extensionType == appExtension }, "the old extension is gone") + assertTrue(group.extensions.any { it.extensionType == 0xC04E }) + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.linux.kt index dfb9a361e6..bf9061c8f6 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.linux.kt @@ -114,6 +114,12 @@ actual object Ed25519 { return privateKey.copyOfRange(SEED_LENGTH, SEED_LENGTH * 2) } + actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { + require(seed.size == SEED_LENGTH) { "Ed25519 seed must be $SEED_LENGTH bytes, was ${seed.size}" } + val publicKey = derivePublicKey(seed) + return Ed25519KeyPair(seed + publicKey, publicKey) + } + private fun derivePublicKey(seed: ByteArray): ByteArray { val d = sha512(seed) d[0] = (d[0].toInt() and 248).toByte()