feat(cordn): full lifecycle interop against ts-mls; fix two engine bugs it found

Adds Staircase (code.relay.tools/opensauce/staircase, d9dd1a0, MIT) as an
interop reference -- an independent Kotlin cordn client that vendors this
project's own MLS engine. Reading it found three defects that 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, with our
own tests perfectly green. MlsGroup.encrypt now takes it, DecryptedMessage
carries it, and CordnApplicationMessage owns the binding and treats an empty
AAD as a rejection rather than an unknown sender.

**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 refuses cordn's 0xC04D metadata commit outright -- and
never checked the real rule, so a commit could install a required_capabilities
a sitting member could not meet and split the group. Both directions fixed,
enforced over the post-commit membership so the RFC's "including those added,
excluding those removed" falls out for free. `MlsGroupPolicy.knownExtensionTypes`
is removed: it encoded the invented rule.

I verified this against the RFC text rather than taking the claim at face
value -- Staircase describes the rule as "every member advertises the type",
which is not what §12.1.7 says either.

**3. `Ed25519` could not rebuild a key pair from a known seed.** Any interop
fixture needs it, and ts-mls stores only the seed, inside a PKCS#8 blob.
`keyPairFromSeed` added across the expect/actual set.

**The lifecycle test** walks a whole group from the other side of the wire,
against fixtures ts-mls generated: read their KeyPackage and agree on its
kp_ref, recognise their last-resort carrier, unseal their commit under the
published epoch-0 exporter, join from their Welcome, derive the same epoch
exporter byte for byte, apply their metadata commit, advance to epoch 2 and
agree again, and read both application messages end to end. The exporter
assertions carry the most: it sits at the end of the whole key schedule, so a
one-bit divergence anywhere upstream gives 32 completely different bytes.

Also: cordn frames handshake messages as PrivateMessage where Marmot uses
PublicMessage, so `decrypt` is the entry point and `processFramedCommit` is
not. And `MlsGroup.memberIdentityHex` hexes the credential bytes -- right for a
raw-key binding, 128 characters of hex-of-hex for cordn -- so cordn has its own
accessor and the trap is now documented where it bites.

Mutation-checked: dropping the AAD in either place kills the send-direction
tests. Those tests exist because the first mutation run survived -- the ts-mls
fixtures only cover the receive direction, so nothing noticed our own encrypt
had stopped writing the field.

quartz 5082 -> 5086, cordn 49 -> 69.

Staircase also independently confirms Stage 1: their VENDORED.md lists the same
Marmot decoupling we landed, arrived at separately 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-18 19:33:13 +00:00
parent 91747296ac
commit 7e36a0eb57
43 changed files with 1042 additions and 56 deletions
+36 -7
View File
@@ -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** (<https://code.relay.tools/opensauce/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.
@@ -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<Int, HexKey> = 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<HexKey> = 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
@@ -91,8 +91,6 @@ object CordnGroupPolicy : MlsGroupPolicy {
*/
override val defaultRequiredCapabilities: Extension? get() = null
override val knownExtensionTypes: Set<Int> get() = setOf(CordnGroupMetadata.EXTENSION_TYPE)
/**
* `MLS-Exporter("cordn", "group-payload", 32)` — `spec/03.md` §4.
*
@@ -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,
)
@@ -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<IllegalArgumentException> { 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<IllegalArgumentException> { 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)
}
}
@@ -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)
}
}
@@ -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<V> || opaque hpke_private_key<V> || opaque signature_private_key<V>
* ```
*
* 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()
}
}
@@ -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** — <https://code.relay.tools/opensauce/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`.
@@ -0,0 +1 @@
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
@@ -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==
@@ -0,0 +1 @@
AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAQFAQGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEcClw/xgK9wHoxslXko2Mtf6lsYARPc+TLlYT9qEF6ofy1MNiHjW4JdFsUYHHvJlehr2R4Z03Urnxq9RZeUxQ3+NwuJQj83Uu3ooow9VBYbbqvW5nAngQOzmnbtIt+GxzfxQWRQyHIkiJpNaoKMVmmbjDhWMQ6ZrXZNxmKHFTU5IT+jqcaF1yzbR5a+mbftI9vTYKrQwzfcVduVUFoUePKFb+4W6TR7AGM8FBxidJPaS0l+Q7WHaK5pinNi+q1bpk5O5TigivuypkJMsLBid3mbcYylQf7Vhz0UGSlHJX0qFvsZIflInpBdUWqbSZVcjIu6AQNqyE0mu1kIuZD229x4687PwxN0wEZkBmQ9k02SzwyBtFwnPp4scQpqiV7cZSMMfjA85Hm2MngZJZmftIGq66GPXPm9WWD/3Jfo/oh6Q4/Y2QoIvVkDC/uo60dudwsDcFnOgJ10XM3w50eyfbpLLxdd9GKtOYsmK6dGxiZl46tZ4fPSh+PLemtnleSP8Bdh4+jyfwmP0mt5p8amyY0fSZPOC2iwP2l
@@ -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
@@ -0,0 +1 @@
AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAgFAQGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEc9O2FAe3ZQqYftiKbAFK9k30vIRV5VdnsMbkGC0HQBhwc/60MQyixbwotWEqvz8i8J/Go608Y61remWpuyl6zbxQETHEnkLcMTJJuJSFZZyKQXFbE+Rnsj1JdBTB7P9PjtOgpTbsyvPO31D2MeMymblzXkjMMw/3hUXZVCoDKiWmIuS6AahAS1vmkEzvJjaTItFP/pkTIk3FmcREouSajlhio3L70BC02clU6sGcu68Kb3OulfviPDc0AwLuvD2sOfLctYI6CHQQs9eFHafA48yalsMfR0lZyFvrIpr8KNurzOW7qcDo0iqfnuHy5vFPlPwn3qKB/XxP30e+whdog060TgYdbvMCeWXEMYdPOuOgpcSXaTMZnXeqZZTyh/PPLWAbzmFrRsfQoRWmktBikW3/nZyE6j3c8FGywrMiq7/kE8AXkC70Wh2nnLwA3PTbzvqo3uVXq3J06YNhi+DUCgVNoEZfcK5fpTCDoo7CLBJFofhXrrccM4pPCpm8wkpEvyeLi97nKOhsBnAIjTif8zdrrN0yhZVvlePuCUhFRDtVbcN9hR+6R/3sXHDMZ0rvDlpG2QHeZ/OtXOouLUngOE1L9h+q5iuYAsI7cZeXmdiv2+4Ut2I4X8038bWrNwbjffD2vpVQCYMuIgkk9FEQ=
Binary file not shown.
@@ -0,0 +1 @@
946816156873cffa538f63644a2d44624d48e8fbb6b0958c58531d80f0e75f33
@@ -0,0 +1 @@
a8b705cda5b133634d31f4e2f4438186b25b91d23d159600663ba2560fc58ffd
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
Binary file not shown.
@@ -0,0 +1 @@
c58c248465ee592c041b961981be8080bc6e772a5743138dbf3ccee8c3c0fcdb
@@ -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==
@@ -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
@@ -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==
@@ -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
@@ -0,0 +1 @@
{"pubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","created_at":1700000000,"kind":9,"tags":[["p","bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]],"content":"hello from ts-mls","id":"92726892b7665f0c2bf53733e52f50c01f202a756696e58eb84e64177ce72601"}
@@ -0,0 +1 @@
{"pubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","created_at":1700000002,"kind":1111,"tags":[["e","92726892b7665f0c2bf53733e52f50c01f202a756696e58eb84e64177ce72601","","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],["k","9"]],"content":"reply at epoch 2 ✨","id":"e73aa0bc24b3f5fcb02547b00045d006859427888cc8f549048b1fd6ddf12234"}
@@ -0,0 +1 @@
141ec831ebd0c989ad977e6dd9b28eeaee8a608ff7fe137d52f571aa4045b680
@@ -0,0 +1 @@
b4d177e140624c206b3e1890138b17cc2a2f889c3569faf3ccea77e6d6ed3d31
@@ -0,0 +1 @@
b0ba537f4b2256e7c3473fbd078ebbc0362a81b0b5a0bcd0572e5b8ea0e7a593
+1
View File
@@ -0,0 +1 @@
staircase-conformance-gid-1
@@ -0,0 +1 @@
{"name":"Conformance","description":"ts-mls ↔ staircase","adminPubkeys":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],"icon":"🪜"}
@@ -0,0 +1 @@
{"name":"Conformance (renamed)","description":"updated by ts-mls","adminPubkeys":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],"icon":"🪜","imageUrl":"https://example.invalid/g.png"}
@@ -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=
+56 -2
View File
@@ -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
@@ -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. */
@@ -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<Int> 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
@@ -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(
@@ -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
}
}
@@ -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<Int> get() = emptySet()
/**
* How this binding derives the pre-commit exporter secret a [CommitResult]
* carries, or null if it seals nothing outside MLS.
@@ -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()
@@ -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<Int>): 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<IllegalStateException> { 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 })
}
}
@@ -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()