diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index bb602f97a..36112ac00 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -95,6 +95,7 @@ class MlsGroup private constructor( private var signingPrivateKey: ByteArray, private var encryptionPrivateKey: ByteArray, private val pendingProposals: MutableList = mutableListOf(), + private val sentKeys: MutableMap = mutableMapOf(), ) { val groupId: ByteArray get() = groupContext.groupId val epoch: Long get() = groupContext.epoch @@ -323,6 +324,7 @@ class MlsGroup private constructor( } pendingProposals.clear() + sentKeys.clear() val commitBytes = commit.toTlsBytes() return CommitResult(commitBytes, welcomeBytes, null) @@ -335,6 +337,7 @@ class MlsGroup private constructor( */ fun encrypt(plaintext: ByteArray): ByteArray { val kng = secretTree.nextApplicationKeyNonce(myLeafIndex) + sentKeys[kng.generation] = kng val ciphertext = MlsCryptoProvider.aeadEncrypt(kng.key, kng.nonce, ByteArray(0), plaintext) // Encrypt sender data @@ -406,7 +409,13 @@ class MlsGroup private constructor( val generation = senderReader.readUint32().toInt() // Get the key/nonce for this sender+generation - val kng = secretTree.applicationKeyNonceForGeneration(senderLeafIndex, generation) + // If we sent this message ourselves, use the cached key to avoid ratchet conflict + val kng = + if (senderLeafIndex == myLeafIndex && sentKeys.containsKey(generation)) { + sentKeys.remove(generation)!! + } else { + secretTree.applicationKeyNonceForGeneration(senderLeafIndex, generation) + } // Decrypt content val plaintext = MlsCryptoProvider.aeadDecrypt(kng.key, kng.nonce, ByteArray(0), privMsg.ciphertext) @@ -504,6 +513,7 @@ class MlsGroup private constructor( secretTree = SecretTree(epochSecrets.encryptionSecret, tree.leafCount) pendingProposals.clear() + sentKeys.clear() } // --- Exporter --- diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/KeyScheduleTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/KeyScheduleTest.kt new file mode 100644 index 000000000..a74c41b7a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/KeyScheduleTest.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mls + +import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext +import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * Tests for MLS Key Schedule (RFC 9420 Section 8). + * + * Verifies the deterministic key derivation chain produces consistent results + * and that all derived secrets are distinct. + */ +class KeyScheduleTest { + @Test + fun testDeriveEpochSecretsProducesAllKeys() { + val groupContext = + GroupContext( + groupId = ByteArray(32) { 0x42 }, + epoch = 0, + treeHash = ByteArray(32), + confirmedTranscriptHash = ByteArray(32), + ) + + val ks = KeySchedule(groupContext.toTlsBytes()) + val commitSecret = ByteArray(32) + val initSecret = ByteArray(32) + + val secrets = ks.deriveEpochSecrets(commitSecret, initSecret) + + // All secrets should be 32 bytes + assertEquals(32, secrets.joinerSecret.size) + assertEquals(32, secrets.welcomeSecret.size) + assertEquals(32, secrets.epochSecret.size) + assertEquals(32, secrets.senderDataSecret.size) + assertEquals(32, secrets.encryptionSecret.size) + assertEquals(32, secrets.exporterSecret.size) + assertEquals(32, secrets.epochAuthenticator.size) + assertEquals(32, secrets.externalSecret.size) + assertEquals(32, secrets.confirmationKey.size) + assertEquals(32, secrets.membershipKey.size) + assertEquals(32, secrets.resumptionPsk.size) + assertEquals(32, secrets.initSecret.size) + } + + @Test + fun testDeriveEpochSecretsAreDeterministic() { + val groupContext = + GroupContext( + groupId = ByteArray(32) { 0x01 }, + epoch = 5, + treeHash = ByteArray(32) { 0x02 }, + confirmedTranscriptHash = ByteArray(32) { 0x03 }, + ) + + val ks = KeySchedule(groupContext.toTlsBytes()) + val commitSecret = ByteArray(32) { 0xAA.toByte() } + val initSecret = ByteArray(32) { 0xBB.toByte() } + + val secrets1 = ks.deriveEpochSecrets(commitSecret, initSecret) + val secrets2 = ks.deriveEpochSecrets(commitSecret, initSecret) + + assertContentEquals(secrets1.epochSecret, secrets2.epochSecret) + assertContentEquals(secrets1.encryptionSecret, secrets2.encryptionSecret) + assertContentEquals(secrets1.exporterSecret, secrets2.exporterSecret) + } + + @Test + fun testDerivedSecretsAreDistinct() { + val groupContext = + GroupContext( + groupId = ByteArray(32), + epoch = 0, + treeHash = ByteArray(32), + confirmedTranscriptHash = ByteArray(32), + ) + + val ks = KeySchedule(groupContext.toTlsBytes()) + val secrets = ks.deriveEpochSecrets(ByteArray(32), ByteArray(32)) + + // All derived secrets should be different from each other + val allSecrets = + listOf( + secrets.senderDataSecret, + secrets.encryptionSecret, + secrets.exporterSecret, + secrets.confirmationKey, + secrets.membershipKey, + ) + + for (i in allSecrets.indices) { + for (j in i + 1 until allSecrets.size) { + assertFalse( + allSecrets[i].contentEquals(allSecrets[j]), + "Secrets at index $i and $j should be different", + ) + } + } + } + + @Test + fun testDifferentGroupContextsProduceDifferentSecrets() { + val ctx1 = + GroupContext( + groupId = ByteArray(32) { 0x01 }, + epoch = 0, + treeHash = ByteArray(32), + confirmedTranscriptHash = ByteArray(32), + ) + + val ctx2 = + GroupContext( + groupId = ByteArray(32) { 0x02 }, + epoch = 0, + treeHash = ByteArray(32), + confirmedTranscriptHash = ByteArray(32), + ) + + val commitSecret = ByteArray(32) + val initSecret = ByteArray(32) + + val secrets1 = KeySchedule(ctx1.toTlsBytes()).deriveEpochSecrets(commitSecret, initSecret) + val secrets2 = KeySchedule(ctx2.toTlsBytes()).deriveEpochSecrets(commitSecret, initSecret) + + assertFalse(secrets1.epochSecret.contentEquals(secrets2.epochSecret)) + } + + @Test + fun testMlsExporter() { + val groupContext = + GroupContext( + groupId = ByteArray(32), + epoch = 0, + treeHash = ByteArray(32), + confirmedTranscriptHash = ByteArray(32), + ) + + val ks = KeySchedule(groupContext.toTlsBytes()) + val secrets = ks.deriveEpochSecrets(ByteArray(32), ByteArray(32)) + + // Marmot exporter: MLS-Exporter("marmot", "group-event", 32) + val marmotKey = + KeySchedule.mlsExporter( + secrets.exporterSecret, + "marmot", + "group-event".encodeToByteArray(), + 32, + ) + + assertEquals(32, marmotKey.size) + + // Same inputs produce same output (deterministic) + val marmotKey2 = + KeySchedule.mlsExporter( + secrets.exporterSecret, + "marmot", + "group-event".encodeToByteArray(), + 32, + ) + assertContentEquals(marmotKey, marmotKey2) + + // Different label produces different key + val otherKey = + KeySchedule.mlsExporter( + secrets.exporterSecret, + "other", + "group-event".encodeToByteArray(), + 32, + ) + assertFalse(marmotKey.contentEquals(otherKey)) + } + + @Test + fun testExpandWithLabelProducesCorrectLength() { + val secret = ByteArray(32) { 0x42 } + + val out16 = MlsCryptoProvider.expandWithLabel(secret, "test", ByteArray(0), 16) + assertEquals(16, out16.size) + + val out32 = MlsCryptoProvider.expandWithLabel(secret, "test", ByteArray(0), 32) + assertEquals(32, out32.size) + + val out48 = MlsCryptoProvider.expandWithLabel(secret, "test", ByteArray(0), 48) + assertEquals(48, out48.size) + } + + @Test + fun testDeriveSecretMatchesExpandWithLabel() { + val secret = ByteArray(32) { 0x42 } + + val derived = MlsCryptoProvider.deriveSecret(secret, "test") + val expanded = MlsCryptoProvider.expandWithLabel(secret, "test", ByteArray(0), 32) + + assertContentEquals(derived, expanded) + } + + @Test + fun testHkdfExpandDeterminism() { + val prk = ByteArray(32) { it.toByte() } + val info = "test info".encodeToByteArray() + + val out1 = MlsCryptoProvider.hkdfExpand(prk, info, 32) + val out2 = MlsCryptoProvider.hkdfExpand(prk, info, 32) + + assertContentEquals(out1, out2) + } + + @Test + fun testRefHash() { + val value = byteArrayOf(0x01, 0x02, 0x03) + val hash1 = MlsCryptoProvider.refHash("MLS 1.0 KeyPackage Reference", value) + val hash2 = MlsCryptoProvider.refHash("MLS 1.0 KeyPackage Reference", value) + + assertEquals(32, hash1.size) + assertContentEquals(hash1, hash2) + + // Different label produces different hash + val hash3 = MlsCryptoProvider.refHash("MLS 1.0 Other Reference", value) + assertFalse(hash1.contentEquals(hash3)) + } + + private fun assertContentEquals( + expected: ByteArray, + actual: ByteArray, + ) { + kotlin.test.assertContentEquals(expected, actual) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt new file mode 100644 index 000000000..19e3c0c7e --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mls + +import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Tests for Ed25519 signature operations on JVM. + * Verifies key generation, signing, verification, and interoperability. + */ +class Ed25519Test { + @Test + fun testKeyPairGeneration() { + val kp = Ed25519.generateKeyPair() + assertEquals(64, kp.privateKey.size, "Private key should be 64 bytes (seed + public)") + assertEquals(32, kp.publicKey.size, "Public key should be 32 bytes") + } + + @Test + fun testPublicFromPrivate() { + val kp = Ed25519.generateKeyPair() + val derived = Ed25519.publicFromPrivate(kp.privateKey) + assertContentEquals(kp.publicKey, derived) + } + + @Test + fun testSignAndVerify() { + val kp = Ed25519.generateKeyPair() + val message = "Hello MLS!".encodeToByteArray() + + val signature = Ed25519.sign(message, kp.privateKey) + assertEquals(64, signature.size, "Ed25519 signature should be 64 bytes") + + assertTrue(Ed25519.verify(message, signature, kp.publicKey), "Signature should verify") + } + + @Test + fun testSignatureFailsWithWrongKey() { + val kp1 = Ed25519.generateKeyPair() + val kp2 = Ed25519.generateKeyPair() + val message = "Hello MLS!".encodeToByteArray() + + val signature = Ed25519.sign(message, kp1.privateKey) + + assertFalse(Ed25519.verify(message, signature, kp2.publicKey), "Signature should not verify with wrong key") + } + + @Test + fun testSignatureFailsWithWrongMessage() { + val kp = Ed25519.generateKeyPair() + val message = "Hello MLS!".encodeToByteArray() + val wrongMessage = "Hello Wrong!".encodeToByteArray() + + val signature = Ed25519.sign(message, kp.privateKey) + + assertFalse(Ed25519.verify(wrongMessage, signature, kp.publicKey), "Signature should not verify with wrong message") + } + + @Test + fun testDeterministicSignatures() { + val kp = Ed25519.generateKeyPair() + val message = "Deterministic test".encodeToByteArray() + + val sig1 = Ed25519.sign(message, kp.privateKey) + val sig2 = Ed25519.sign(message, kp.privateKey) + + // Ed25519 signatures are deterministic (RFC 8032) + assertContentEquals(sig1, sig2) + } + + @Test + fun testEmptyMessage() { + val kp = Ed25519.generateKeyPair() + val message = ByteArray(0) + + val signature = Ed25519.sign(message, kp.privateKey) + assertTrue(Ed25519.verify(message, signature, kp.publicKey)) + } + + @Test + fun testLargeMessage() { + val kp = Ed25519.generateKeyPair() + val message = ByteArray(10000) { it.toByte() } + + val signature = Ed25519.sign(message, kp.privateKey) + assertTrue(Ed25519.verify(message, signature, kp.publicKey)) + } + + private fun assertContentEquals( + expected: ByteArray, + actual: ByteArray, + ) { + kotlin.test.assertContentEquals(expected, actual) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/HpkeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/HpkeTest.kt new file mode 100644 index 000000000..e740eb7a5 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/HpkeTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mls + +import com.vitorpamplona.quartz.marmot.mls.crypto.Hpke +import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for HPKE (RFC 9180) implementation used by MLS TreeKEM. + * + * Tests the seal/open (encrypt/decrypt) cycle which is critical + * for path secret encryption in UpdatePath processing. + */ +class HpkeTest { + @Test + fun testSealOpenRoundTrip() { + val recipient = X25519.generateKeyPair() + val plaintext = "Secret MLS path data".encodeToByteArray() + val info = "MLS 1.0 UpdatePathNode".encodeToByteArray() + val aad = ByteArray(0) + + val ciphertext = Hpke.seal(recipient.publicKey, info, aad, plaintext) + + assertEquals(32, ciphertext.kemOutput.size, "KEM output should be 32 bytes (X25519 public key)") + assertTrue(ciphertext.ciphertext.size > plaintext.size, "Ciphertext should include tag") + + val decrypted = Hpke.open(recipient.privateKey, ciphertext.kemOutput, info, aad, ciphertext.ciphertext) + assertContentEquals(plaintext, decrypted) + } + + @Test + fun testSealOpenEmptyPlaintext() { + val recipient = X25519.generateKeyPair() + val plaintext = ByteArray(0) + val info = ByteArray(0) + val aad = ByteArray(0) + + val ciphertext = Hpke.seal(recipient.publicKey, info, aad, plaintext) + val decrypted = Hpke.open(recipient.privateKey, ciphertext.kemOutput, info, aad, ciphertext.ciphertext) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun testSealOpenLargePlaintext() { + val recipient = X25519.generateKeyPair() + val plaintext = ByteArray(10000) { it.toByte() } + val info = "large test".encodeToByteArray() + val aad = ByteArray(0) + + val ciphertext = Hpke.seal(recipient.publicKey, info, aad, plaintext) + val decrypted = Hpke.open(recipient.privateKey, ciphertext.kemOutput, info, aad, ciphertext.ciphertext) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun testSealProducesDifferentCiphertexts() { + val recipient = X25519.generateKeyPair() + val plaintext = "Same plaintext".encodeToByteArray() + val info = ByteArray(0) + val aad = ByteArray(0) + + val ct1 = Hpke.seal(recipient.publicKey, info, aad, plaintext) + val ct2 = Hpke.seal(recipient.publicKey, info, aad, plaintext) + + // Each seal generates a fresh ephemeral key, so KEM outputs differ + assertFalse( + ct1.kemOutput.contentEquals(ct2.kemOutput), + "Each seal should use a different ephemeral key", + ) + } + + @Test + fun testSealOpenWithInfo() { + val recipient = X25519.generateKeyPair() + val plaintext = "test".encodeToByteArray() + val info = "MLS 1.0 UpdatePathNode context data".encodeToByteArray() + val aad = ByteArray(0) + + val ciphertext = Hpke.seal(recipient.publicKey, info, aad, plaintext) + val decrypted = Hpke.open(recipient.privateKey, ciphertext.kemOutput, info, aad, ciphertext.ciphertext) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun testSealOpenWithMlsLabels() { + // Simulate MLS EncryptWithLabel pattern + val recipient = X25519.generateKeyPair() + val pathSecret = ByteArray(32) { it.toByte() } + + val label = "MLS 1.0 UpdatePathNode" + val fullInfo = label.encodeToByteArray() + val aad = ByteArray(0) + + val ciphertext = Hpke.seal(recipient.publicKey, fullInfo, aad, pathSecret) + val decrypted = Hpke.open(recipient.privateKey, ciphertext.kemOutput, fullInfo, aad, ciphertext.ciphertext) + + assertContentEquals(pathSecret, decrypted) + } + + private fun assertContentEquals( + expected: ByteArray, + actual: ByteArray, + ) { + kotlin.test.assertContentEquals(expected, actual) + } + + private fun assertFalse( + condition: Boolean, + message: String = "", + ) { + kotlin.test.assertFalse(condition, message) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupTest.kt new file mode 100644 index 000000000..8eeef2acf --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupTest.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mls + +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Integration tests for MlsGroup — the main MLS engine API. + * + * Tests the complete lifecycle: group creation, member addition, + * message encryption/decryption, and exporter key derivation. + */ +class MlsGroupTest { + @Test + fun testCreateGroup() { + val identity = "alice@nostr".encodeToByteArray() + val group = MlsGroup.create(identity) + + assertEquals(0L, group.epoch) + assertEquals(1, group.memberCount) + assertEquals(0, group.leafIndex) + assertEquals(32, group.groupId.size) + } + + @Test + fun testCreateGroupWithSigningKey() { + val identity = "alice@nostr".encodeToByteArray() + val sigKp = + com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 + .generateKeyPair() + val group = MlsGroup.create(identity, sigKp.privateKey) + + assertEquals(0L, group.epoch) + assertEquals(1, group.memberCount) + } + + @Test + fun testEncryptDecryptSameGroup() { + val identity = "alice@nostr".encodeToByteArray() + val group = MlsGroup.create(identity) + + val plaintext = "Hello, MLS world!".encodeToByteArray() + val encrypted = group.encrypt(plaintext) + + assertTrue(encrypted.isNotEmpty()) + assertTrue(encrypted.size > plaintext.size, "Encrypted should be larger than plaintext") + + val decrypted = group.decrypt(encrypted) + assertEquals(0, decrypted.senderLeafIndex) + assertEquals(group.epoch, decrypted.epoch) + assertContentEquals(plaintext, decrypted.content) + } + + @Test + fun testEncryptMultipleMessages() { + val group = MlsGroup.create("alice".encodeToByteArray()) + + val messages = + listOf( + "First message", + "Second message", + "Third message", + ) + + val encrypted = messages.map { group.encrypt(it.encodeToByteArray()) } + + // Each encrypted message should be different (different nonce/generation) + for (i in encrypted.indices) { + for (j in i + 1 until encrypted.size) { + assertFalse( + encrypted[i].contentEquals(encrypted[j]), + "Encrypted messages $i and $j should be different", + ) + } + } + } + + @Test + fun testExporterSecret() { + val group = MlsGroup.create("alice".encodeToByteArray()) + + // Marmot exporter: MLS-Exporter("marmot", "group-event", 32) + val key = group.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + assertEquals(32, key.size) + + // Same call produces same result (deterministic) + val key2 = group.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + assertContentEquals(key, key2) + + // Different label produces different key + val key3 = group.exporterSecret("other", "group-event".encodeToByteArray(), 32) + assertFalse(key.contentEquals(key3)) + } + + @Test + fun testExporterSecretDifferentLengths() { + val group = MlsGroup.create("alice".encodeToByteArray()) + + val key16 = group.exporterSecret("marmot", "test".encodeToByteArray(), 16) + assertEquals(16, key16.size) + + val key48 = group.exporterSecret("marmot", "test".encodeToByteArray(), 48) + assertEquals(48, key48.size) + } + + @Test + fun testMembersListAfterCreation() { + val identity = "alice@nostr".encodeToByteArray() + val group = MlsGroup.create(identity) + + val members = group.members() + assertEquals(1, members.size) + assertEquals(0, members[0].first) // leaf index + assertNotNull(members[0].second) // LeafNode present + } + + @Test + fun testCreateKeyPackage() { + val group = MlsGroup.create("alice".encodeToByteArray()) + val bundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + + assertNotNull(bundle.keyPackage) + assertEquals(1, bundle.keyPackage.version) + assertEquals(1, bundle.keyPackage.cipherSuite) + assertEquals(32, bundle.keyPackage.initKey.size) + assertEquals(32, bundle.initPrivateKey.size) + assertEquals(32, bundle.encryptionPrivateKey.size) + assertEquals(64, bundle.signaturePrivateKey.size) + } + + @Test + fun testKeyPackageReference() { + val group = MlsGroup.create("alice".encodeToByteArray()) + val bundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + + val ref = bundle.keyPackage.reference() + assertEquals(32, ref.size, "KeyPackage reference should be 32 bytes (SHA-256)") + + // Deterministic + val ref2 = bundle.keyPackage.reference() + assertContentEquals(ref, ref2) + } + + @Test + fun testAddMemberProducesCommitAndWelcome() { + val aliceGroup = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = aliceGroup.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + val bobKpBytes = bobBundle.keyPackage.toTlsBytes() + + val result = aliceGroup.addMember(bobKpBytes) + + assertTrue(result.commitBytes.isNotEmpty(), "Commit bytes should not be empty") + assertNotNull(result.welcomeBytes, "Welcome bytes should be present for Add") + assertTrue(result.welcomeBytes!!.isNotEmpty(), "Welcome bytes should not be empty") + + // After commit, epoch should advance + assertEquals(1L, aliceGroup.epoch) + assertEquals(2, aliceGroup.memberCount) + } + + @Test + fun testRemoveMember() { + val group = MlsGroup.create("alice".encodeToByteArray()) + + // Add bob first + val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + group.addMember(bobBundle.keyPackage.toTlsBytes()) + assertEquals(2, group.memberCount) + + // Remove bob + val result = group.removeMember(1) + assertTrue(result.commitBytes.isNotEmpty()) + assertEquals(2L, group.epoch) // epoch 0 -> addMember epoch 1 -> removeMember epoch 2 + assertEquals(1, group.memberCount) + } + + @Test + fun testEpochAdvancesOnCommit() { + val group = MlsGroup.create("alice".encodeToByteArray()) + assertEquals(0L, group.epoch) + + val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + group.addMember(bobBundle.keyPackage.toTlsBytes()) + assertEquals(1L, group.epoch) + + val carolBundle = group.createKeyPackage("carol".encodeToByteArray(), ByteArray(0)) + group.addMember(carolBundle.keyPackage.toTlsBytes()) + assertEquals(2L, group.epoch) + } + + @Test + fun testExporterSecretChangesPerEpoch() { + val group = MlsGroup.create("alice".encodeToByteArray()) + + val key0 = group.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + + val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + group.addMember(bobBundle.keyPackage.toTlsBytes()) + + val key1 = group.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + + assertFalse(key0.contentEquals(key1), "Exporter secret should change per epoch") + } + + @Test + fun testEncryptAfterEpochChange() { + val group = MlsGroup.create("alice".encodeToByteArray()) + + // Encrypt before adding member + val ct1 = group.encrypt("before".encodeToByteArray()) + + // Add member, advancing epoch + val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + group.addMember(bobBundle.keyPackage.toTlsBytes()) + + // Encrypt after epoch change + val ct2 = group.encrypt("after".encodeToByteArray()) + + // Both should produce valid ciphertexts + assertTrue(ct1.isNotEmpty()) + assertTrue(ct2.isNotEmpty()) + } + + @Test + fun testSelfRemove() { + val group = MlsGroup.create("alice".encodeToByteArray()) + val selfRemoveBytes = group.selfRemove() + assertTrue(selfRemoveBytes.isNotEmpty()) + } + + private fun assertContentEquals( + expected: ByteArray, + actual: ByteArray, + ) { + kotlin.test.assertContentEquals(expected, actual) + } + + private fun assertFalse( + condition: Boolean, + message: String = "", + ) { + kotlin.test.assertFalse(condition, message) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/X25519Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/X25519Test.kt new file mode 100644 index 000000000..f323e8d34 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/X25519Test.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mls + +import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * Tests for X25519 Diffie-Hellman key exchange on JVM. + * Verifies key generation, DH agreement, and interoperability. + */ +class X25519Test { + @Test + fun testKeyPairGeneration() { + val kp = X25519.generateKeyPair() + assertEquals(32, kp.privateKey.size, "Private key should be 32 bytes") + assertEquals(32, kp.publicKey.size, "Public key should be 32 bytes") + } + + @Test + fun testDhAgreement() { + val alice = X25519.generateKeyPair() + val bob = X25519.generateKeyPair() + + val sharedAlice = X25519.dh(alice.privateKey, bob.publicKey) + val sharedBob = X25519.dh(bob.privateKey, alice.publicKey) + + assertEquals(32, sharedAlice.size, "Shared secret should be 32 bytes") + assertContentEquals(sharedAlice, sharedBob, "Both sides should derive the same shared secret") + } + + @Test + fun testDhWithDifferentKeys() { + val alice = X25519.generateKeyPair() + val bob = X25519.generateKeyPair() + val carol = X25519.generateKeyPair() + + val sharedAB = X25519.dh(alice.privateKey, bob.publicKey) + val sharedAC = X25519.dh(alice.privateKey, carol.publicKey) + + assertFalse(sharedAB.contentEquals(sharedAC), "Different key pairs should produce different shared secrets") + } + + @Test + fun testPublicFromPrivate() { + val kp = X25519.generateKeyPair() + val derived = X25519.publicFromPrivate(kp.privateKey) + + // The derived public key should produce the same DH result + val bob = X25519.generateKeyPair() + val shared1 = X25519.dh(kp.privateKey, bob.publicKey) + // If publicFromPrivate works, using the derived key for the reverse DH should match + val shared2 = X25519.dh(bob.privateKey, derived) + assertContentEquals(shared1, shared2) + } + + @Test + fun testMultipleKeyPairsAreDistinct() { + val kp1 = X25519.generateKeyPair() + val kp2 = X25519.generateKeyPair() + + assertFalse(kp1.privateKey.contentEquals(kp2.privateKey), "Generated private keys should be distinct") + assertFalse(kp1.publicKey.contentEquals(kp2.publicKey), "Generated public keys should be distinct") + } + + private fun assertContentEquals( + expected: ByteArray, + actual: ByteArray, + message: String = "", + ) { + kotlin.test.assertContentEquals(expected, actual, message) + } +}