mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+36
-7
@@ -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.
|
||||
*
|
||||
|
||||
+128
@@ -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,
|
||||
)
|
||||
+119
@@ -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)
|
||||
}
|
||||
}
|
||||
+284
@@ -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
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
a8b705cda5b133634d31f4e2f4438186b25b91d23d159600663ba2560fc58ffd
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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=
|
||||
@@ -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. */
|
||||
|
||||
-6
@@ -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()
|
||||
|
||||
+118
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user