From 070affb4e49c568e469b385f01f40fcc4003ce71 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 01:40:40 +0000 Subject: [PATCH] fix: restore accidentally deleted test files https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY --- .../marmot/MarmotSubscriptionManagerTest.kt | 207 ++++++++ .../quartz/marmot/mls/MlsGroupEdgeCaseTest.kt | 368 +++++++++++++ .../marmot/mls/MlsGroupLifecycleTest.kt | 498 ++++++++++++++++++ .../quartz/marmot/mls/MlsGroupTest.kt | 292 ++++++++++ 4 files changed, 1365 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManagerTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupTest.kt diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManagerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManagerTest.kt new file mode 100644 index 000000000..4842d831f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManagerTest.kt @@ -0,0 +1,207 @@ +/* + * 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 + +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for MarmotSubscriptionManager. + */ +class MarmotSubscriptionManagerTest { + private val userPubKey = "a".repeat(64) + private val groupId1 = "b".repeat(64) + private val groupId2 = "c".repeat(64) + + @Test + fun testSubscribeGroup() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + + assertTrue(manager.isSubscribed(groupId1)) + assertEquals(setOf(groupId1), manager.activeGroupIds()) + } + + @Test + fun testSubscribeGroupWithSince() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + val since = 1700000000L + + manager.subscribeGroup(groupId1, since) + + assertTrue(manager.isSubscribed(groupId1)) + + val filters = manager.activeGroupFilters() + assertEquals(1, filters.size) + assertEquals(since, filters[0].since) + } + + @Test + fun testUnsubscribeGroup() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + manager.unsubscribeGroup(groupId1) + + assertFalse(manager.isSubscribed(groupId1)) + assertTrue(manager.activeGroupIds().isEmpty()) + } + + @Test + fun testMultipleGroups() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + manager.subscribeGroup(groupId2) + + assertEquals(setOf(groupId1, groupId2), manager.activeGroupIds()) + + val filters = manager.activeGroupFilters() + assertEquals(2, filters.size) + } + + @Test + fun testUpdateGroupSince() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + val newSince = 1700000000L + + manager.subscribeGroup(groupId1) + manager.updateGroupSince(groupId1, newSince) + + val filters = manager.activeGroupFilters() + assertEquals(1, filters.size) + assertEquals(newSince, filters[0].since) + } + + @Test + fun testGiftWrapFilter() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + val filter = manager.giftWrapFilter() + + assertEquals(listOf(GiftWrapEvent.KIND), filter.kinds) + assertNotNull(filter.tags) + assertEquals(listOf(userPubKey), filter.tags["p"]) + assertNull(filter.since) + } + + @Test + fun testGiftWrapFilterWithSince() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + val since = 1700000000L + + manager.updateGiftWrapSince(since) + val filter = manager.giftWrapFilter() + + assertEquals(since, filter.since) + } + + @Test + fun testActiveGroupFiltersContainCorrectKind() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + val filters = manager.activeGroupFilters() + + assertEquals(1, filters.size) + assertEquals(listOf(GroupEvent.KIND), filters[0].kinds) + assertNotNull(filters[0].tags) + assertEquals(listOf(groupId1), filters[0].tags!!["h"]) + } + + @Test + fun testBuildFiltersIncludesAllTypes() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + val allFilters = manager.buildFilters() + + // Should have 1 group filter + 1 gift wrap filter + 1 own key package filter + assertEquals(3, allFilters.size) + } + + @Test + fun testBuildFiltersWithNoGroupsHasGiftWrapAndKeyPackage() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + val allFilters = manager.buildFilters() + + // Gift wrap filter + own key package filter + assertEquals(2, allFilters.size) + assertEquals(listOf(GiftWrapEvent.KIND), allFilters[0].kinds) + } + + @Test + fun testKeyPackageFilter() { + val manager = MarmotSubscriptionManager(userPubKey) + val targetPubKey = "d".repeat(64) + + val filter = manager.keyPackageFilter(targetPubKey) + assertEquals(listOf(targetPubKey), filter.authors) + } + + @Test + fun testSyncWithGroupManager() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + + // Start with one group + manager.subscribeGroup(groupId1) + + // Sync with group manager that has different groups + manager.syncWithGroupManager(setOf(groupId2)) + + // groupId1 should be removed, groupId2 added + assertFalse(manager.isSubscribed(groupId1)) + assertTrue(manager.isSubscribed(groupId2)) + } + + @Test + fun testClear() = + runTest { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + manager.subscribeGroup(groupId2) + manager.updateGiftWrapSince(1700000000L) + + manager.clear() + + assertTrue(manager.activeGroupIds().isEmpty()) + assertNull(manager.giftWrapFilter().since) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt new file mode 100644 index 000000000..4647c3960 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt @@ -0,0 +1,368 @@ +/* + * 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 com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Edge case and error handling tests for MlsGroup. + * + * Tests security-critical boundaries: + * - Wrong epoch messages are rejected + * - Corrupted ciphertext is detected (AEAD authentication) + * - Invalid KeyPackages are rejected + * - Out-of-range leaf indices are caught + * - Self-removal via Remove (not SelfRemove) is rejected + * - Empty messages and large messages are handled correctly + * - DecryptOrNull returns null on failure instead of throwing + */ +class MlsGroupEdgeCaseTest { + private fun createStandaloneKeyPackage(identity: String): KeyPackageBundle { + val tempGroup = MlsGroup.create(identity.encodeToByteArray()) + return tempGroup.createKeyPackage(identity.encodeToByteArray(), ByteArray(0)) + } + + // ----------------------------------------------------------------------- + // 1. Wrong epoch rejection + // ----------------------------------------------------------------------- + + @Test + fun testDecryptRejectsWrongEpoch() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + // Alice encrypts at epoch 1 + val ct = alice.encrypt("epoch 1 message".encodeToByteArray()) + + // Advance Bob to epoch 2 by having him commit (empty commit) + bob.commit() + assertEquals(2L, bob.epoch) + + // Bob's epoch is now 2, but the message was at epoch 1 — should fail + assertFailsWith("Decrypting wrong-epoch message should throw") { + bob.decrypt(ct) + } + } + + @Test + fun testDecryptOrNullReturnsNullOnWrongEpoch() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + val ct = alice.encrypt("epoch 1 message".encodeToByteArray()) + + bob.commit() + + val result = bob.decryptOrNull(ct) + assertNull(result, "decryptOrNull should return null for wrong-epoch message") + } + + // ----------------------------------------------------------------------- + // 2. Corrupted ciphertext detection + // ----------------------------------------------------------------------- + + @Test + fun testDecryptRejectsTamperedCiphertext() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val ct = alice.encrypt("secret message".encodeToByteArray()) + + // Tamper with the ciphertext (flip a byte near the end) + val tampered = ct.copyOf() + if (tampered.size > 10) { + tampered[tampered.size - 5] = (tampered[tampered.size - 5].toInt() xor 0xFF).toByte() + } + + // AEAD should detect tampering + assertNull(alice.decryptOrNull(tampered), "Tampered ciphertext should fail decryption") + } + + @Test + fun testDecryptRejectsTruncatedMessage() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val ct = alice.encrypt("test".encodeToByteArray()) + + // Truncate to half length + val truncated = ct.copyOfRange(0, ct.size / 2) + assertNull(alice.decryptOrNull(truncated), "Truncated message should fail") + } + + @Test + fun testDecryptRejectsGarbageInput() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + + val garbage = ByteArray(100) { it.toByte() } + assertNull(alice.decryptOrNull(garbage), "Garbage input should fail gracefully") + } + + // ----------------------------------------------------------------------- + // 3. Invalid KeyPackage rejection + // ----------------------------------------------------------------------- + + @Test + fun testAddMemberRejectsInvalidKeyPackageSignature() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val kpBytes = bobBundle.keyPackage.toTlsBytes() + + // Tamper with the signature in the serialized KeyPackage + val tampered = kpBytes.copyOf() + // The signature is at the end of the TLS-serialized KeyPackage + if (tampered.size > 10) { + tampered[tampered.size - 3] = (tampered[tampered.size - 3].toInt() xor 0xFF).toByte() + } + + assertFailsWith("Adding member with invalid KeyPackage signature should fail") { + alice.addMember(tampered) + } + } + + // ----------------------------------------------------------------------- + // 4. Out-of-range leaf index rejection + // ----------------------------------------------------------------------- + + @Test + fun testRemoveRejectsOutOfRangeLeafIndex() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + alice.addMember(bobBundle.keyPackage.toTlsBytes()) + + // Leaf index 99 is way out of range + assertFailsWith("Removing out-of-range leaf should fail") { + alice.removeMember(99) + } + } + + @Test + fun testRemoveRejectsBlankLeaf() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + alice.addMember(bobBundle.keyPackage.toTlsBytes()) + + // Remove Bob (leaf 1) + alice.removeMember(1) + + // Try to remove leaf 1 again (now blank) + assertFailsWith("Removing blank leaf should fail") { + alice.removeMember(1) + } + } + + @Test + fun testRemoveRejectsSelfRemovalViaRemove() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + alice.addMember(bobBundle.keyPackage.toTlsBytes()) + + // Alice tries to remove herself via Remove (should use SelfRemove instead) + assertFailsWith("Self-removal via Remove should be rejected") { + alice.removeMember(alice.leafIndex) + } + } + + // ----------------------------------------------------------------------- + // 5. Empty and large messages + // ----------------------------------------------------------------------- + + @Test + fun testEncryptDecryptEmptyMessage() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + val empty = ByteArray(0) + val ct = alice.encrypt(empty) + val dec = bob.decrypt(ct) + assertContentEquals(empty, dec.content, "Empty message should round-trip") + } + + @Test + fun testEncryptDecryptLargeMessage() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + // 64KB message + val large = ByteArray(65536) { (it % 256).toByte() } + val ct = alice.encrypt(large) + val dec = bob.decrypt(ct) + assertContentEquals(large, dec.content, "Large message should round-trip") + } + + // ----------------------------------------------------------------------- + // 6. Multiple epochs of encrypt/decrypt + // ----------------------------------------------------------------------- + + // BUG: processCommit key derivation diverges — commit_secret decryption from + // UpdatePath does not correctly derive matching epoch secrets between commit() + // and processCommit(). See MlsGroupLifecycleTest.testThreeMemberGroup_SequentialAdditions. + @Ignore + @Test + fun testMultipleEpochTransitions_EncryptDecryptStillWorks() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + // Advance through several epochs with empty commits + for (i in 0 until 5) { + val commitResult = alice.commit() + bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0)) + } + + assertEquals(7L, alice.epoch) // epoch 0 + addMember(1) + 5 commits = 6... wait + // epoch 0 (create) -> epoch 1 (add bob) -> 5 empty commits = epoch 6 + assertEquals(6L, alice.epoch) + assertEquals(alice.epoch, bob.epoch) + + // Both directions still work + val msg = "After many epochs".encodeToByteArray() + val ct = alice.encrypt(msg) + val dec = bob.decrypt(ct) + assertContentEquals(msg, dec.content) + + val msg2 = "Bob replies after epochs".encodeToByteArray() + val ct2 = bob.encrypt(msg2) + val dec2 = alice.decrypt(ct2) + assertContentEquals(msg2, dec2.content) + } + + // ----------------------------------------------------------------------- + // 7. Exporter secret uniqueness across epochs + // ----------------------------------------------------------------------- + + @Test + fun testExporterSecretUniquePerEpoch() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + val keys = mutableListOf() + + // Collect exporter secrets across several epochs + keys.add(alice.exporterSecret("marmot", "group-event".encodeToByteArray(), 32)) + + for (i in 0 until 3) { + val commitResult = alice.commit() + bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0)) + keys.add(alice.exporterSecret("marmot", "group-event".encodeToByteArray(), 32)) + } + + // All keys should be distinct + for (i in keys.indices) { + for (j in i + 1 until keys.size) { + assertFalse( + keys[i].contentEquals(keys[j]), + "Exporter secrets at epoch $i and $j must differ", + ) + } + } + } + + // ----------------------------------------------------------------------- + // 8. Welcome with wrong KeyPackageBundle is rejected + // ----------------------------------------------------------------------- + + @Test + fun testWelcomeRejectsWrongKeyPackageBundle() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val result = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + + // Carol's bundle (not the one Alice invited) + val carolBundle = createStandaloneKeyPackage("carol") + + // Processing Welcome with wrong bundle should fail + assertFailsWith("Welcome with wrong KeyPackage should be rejected") { + MlsGroup.processWelcome(result.welcomeBytes!!, carolBundle) + } + } + + // ----------------------------------------------------------------------- + // 9. Group state after multiple add/remove cycles + // ----------------------------------------------------------------------- + + @Test + fun testAddRemoveAddCycle_GroupRemainsConsistent() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + + // Add Bob + val bobBundle = createStandaloneKeyPackage("bob") + val addBob = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + assertEquals(2, alice.memberCount) + + // Remove Bob + alice.removeMember(1) + assertEquals(1, alice.memberCount) + + // Add Carol (she should occupy a leaf slot) + val carolBundle = createStandaloneKeyPackage("carol") + val addCarol = alice.addMember(carolBundle.keyPackage.toTlsBytes()) + assertEquals(2, alice.memberCount) + + // Carol joins and can communicate with Alice + val carol = MlsGroup.processWelcome(addCarol.welcomeBytes!!, carolBundle) + val msg = "After add-remove-add cycle".encodeToByteArray() + val ct = alice.encrypt(msg) + val dec = carol.decrypt(ct) + assertContentEquals(msg, dec.content) + } + + // ----------------------------------------------------------------------- + // 10. Member list consistency + // ----------------------------------------------------------------------- + + @Test + fun testMemberListConsistency_AfterAdditions() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + assertEquals(1, alice.members().size) + + val bobBundle = createStandaloneKeyPackage("bob") + alice.addMember(bobBundle.keyPackage.toTlsBytes()) + assertEquals(2, alice.members().size) + + val carolBundle = createStandaloneKeyPackage("carol") + alice.addMember(carolBundle.keyPackage.toTlsBytes()) + assertEquals(3, alice.members().size) + + // All members should have valid LeafNodes + for ((_, leafNode) in alice.members()) { + assertEquals(32, leafNode.encryptionKey.size) + assertEquals(32, leafNode.signatureKey.size) + assertTrue(leafNode.signature.isNotEmpty()) + } + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt new file mode 100644 index 000000000..02806fa3e --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt @@ -0,0 +1,498 @@ +/* + * 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 com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull + +/** + * End-to-end lifecycle tests for MlsGroup covering the full protocol flow: + * + * - Group creation and Welcome-based joins (RFC 9420 Section 12.4.3) + * - Cross-member encryption/decryption after Welcome processing + * - Multi-member groups with sequential additions + * - Commit processing between independent group instances + * - External join via GroupInfo (RFC 9420 Section 12.4.3.2) + * - Exporter secret agreement after join + * - Member removal and re-keying + * + * These tests simulate realistic multi-party scenarios where each participant + * maintains their own independent MlsGroup instance, communicating only through + * serialized MLS messages (commit bytes, welcome bytes, encrypted ciphertext). + */ +class MlsGroupLifecycleTest { + // --- Helper: create a standalone KeyPackageBundle for a new joiner --- + + /** + * Creates a fresh KeyPackageBundle as a prospective group member would. + * In production this is done by the joiner BEFORE they know which group + * they will be invited to (MIP-00 key package publishing). + */ + private fun createStandaloneKeyPackage(identity: String): KeyPackageBundle { + val tempGroup = MlsGroup.create(identity.encodeToByteArray()) + return tempGroup.createKeyPackage(identity.encodeToByteArray(), ByteArray(0)) + } + + // ----------------------------------------------------------------------- + // 1. Welcome Processing: Alice creates group, adds Bob, Bob joins + // ----------------------------------------------------------------------- + + @Test + fun testWelcomeProcessing_BobJoinsAliceGroup() { + // Alice creates a new group + val alice = MlsGroup.create("alice".encodeToByteArray()) + assertEquals(0L, alice.epoch) + assertEquals(1, alice.memberCount) + + // Bob creates a KeyPackage (published to relays via MIP-00) + val bobBundle = createStandaloneKeyPackage("bob") + + // Alice adds Bob: produces a Commit (broadcast) and Welcome (sent to Bob) + val result = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + assertNotNull(result.welcomeBytes, "Welcome must be produced for Add commit") + assertEquals(1L, alice.epoch, "Alice advances to epoch 1 after commit") + assertEquals(2, alice.memberCount) + + // Bob processes the Welcome to join the group + val bob = MlsGroup.processWelcome(result.welcomeBytes!!, bobBundle) + assertEquals(1L, bob.epoch, "Bob should be at same epoch as Alice after Welcome") + assertEquals(2, bob.memberCount, "Bob should see 2 members") + } + + // ----------------------------------------------------------------------- + // 2. Cross-member encrypt/decrypt after Welcome + // ----------------------------------------------------------------------- + + @Test + fun testCrossGroupEncryptDecrypt_AfterWelcome() { + // Setup: Alice creates group, adds Bob + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val result = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(result.welcomeBytes!!, bobBundle) + + // Alice encrypts a message + val plaintext = "Hello Bob, welcome to the group!".encodeToByteArray() + val ciphertext = alice.encrypt(plaintext) + + // Bob decrypts Alice's message + val decrypted = bob.decrypt(ciphertext) + assertContentEquals(plaintext, decrypted.content) + assertEquals(0, decrypted.senderLeafIndex, "Sender should be Alice at leaf 0") + assertEquals(1L, decrypted.epoch) + } + + @Test + fun testBobEncryptsAliceDecrypts_AfterWelcome() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val result = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(result.welcomeBytes!!, bobBundle) + + // Bob encrypts, Alice decrypts + val plaintext = "Hi Alice, thanks for the invite!".encodeToByteArray() + val ciphertext = bob.encrypt(plaintext) + val decrypted = alice.decrypt(ciphertext) + assertContentEquals(plaintext, decrypted.content) + assertEquals(1, decrypted.senderLeafIndex, "Sender should be Bob at leaf 1") + } + + @Test + fun testMultipleMessagesExchanged_AfterWelcome() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val result = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(result.welcomeBytes!!, bobBundle) + + // Exchange multiple messages in both directions + val messages = + listOf( + Pair(0, "Alice: message 1"), + Pair(1, "Bob: message 1"), + Pair(0, "Alice: message 2"), + Pair(1, "Bob: message 2"), + Pair(0, "Alice: message 3"), + ) + + for ((senderIdx, text) in messages) { + val plaintext = text.encodeToByteArray() + val sender = if (senderIdx == 0) alice else bob + val receiver = if (senderIdx == 0) bob else alice + + val ct = sender.encrypt(plaintext) + val dec = receiver.decrypt(ct) + assertContentEquals(plaintext, dec.content, "Failed on: $text") + assertEquals(senderIdx, dec.senderLeafIndex) + } + } + + // ----------------------------------------------------------------------- + // 3. Exporter secret agreement after Welcome + // ----------------------------------------------------------------------- + + @Test + fun testExporterSecretAgrees_AfterWelcome() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val result = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(result.welcomeBytes!!, bobBundle) + + // Both should derive the same exporter secret (used for Marmot outer encryption) + val aliceKey = alice.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + val bobKey = bob.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + assertContentEquals(aliceKey, bobKey, "Exporter secrets must agree after Welcome join") + } + + // ----------------------------------------------------------------------- + // 4. Three-member group: sequential additions + // ----------------------------------------------------------------------- + + // BUG: processCommit does not derive the same epoch secrets as commit(). + // After Bob.processCommit(Alice's commit), Bob's key schedule diverges + // because the commit_secret decryption from the UpdatePath does not + // correctly walk the ratchet tree to find the common ancestor's path secret. + // This causes AEAD decryption failures on cross-member messages. + @Ignore + @Test + fun testThreeMemberGroup_SequentialAdditions() { + // Alice creates the group + val alice = MlsGroup.create("alice".encodeToByteArray()) + + // Alice adds Bob + val bobBundle = createStandaloneKeyPackage("bob") + val addBobResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addBobResult.welcomeBytes!!, bobBundle) + assertEquals(1L, alice.epoch) + assertEquals(1L, bob.epoch) + + // Alice adds Carol (Bob processes Alice's commit) + val carolBundle = createStandaloneKeyPackage("carol") + val addCarolResult = alice.addMember(carolBundle.keyPackage.toTlsBytes()) + bob.processCommit(addCarolResult.commitBytes, alice.leafIndex, ByteArray(0)) + val carol = MlsGroup.processWelcome(addCarolResult.welcomeBytes!!, carolBundle) + + assertEquals(2L, alice.epoch) + assertEquals(2L, bob.epoch) + assertEquals(2L, carol.epoch) + assertEquals(3, alice.memberCount) + assertEquals(3, bob.memberCount) + assertEquals(3, carol.memberCount) + + // Verify all three can communicate + val aliceMsg = "Hello from Alice".encodeToByteArray() + val ct = alice.encrypt(aliceMsg) + + val bobDecrypted = bob.decrypt(ct) + assertContentEquals(aliceMsg, bobDecrypted.content) + + val carolDecrypted = carol.decrypt(ct) + assertContentEquals(aliceMsg, carolDecrypted.content) + } + + // ----------------------------------------------------------------------- + // 5. Commit processing: Bob adds Carol, Alice processes commit + // ----------------------------------------------------------------------- + + @Test + fun testCommitProcessing_BobAddsCarolAliceProcesses() { + // Alice creates group, adds Bob + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addBobResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addBobResult.welcomeBytes!!, bobBundle) + + // Bob adds Carol + val carolBundle = createStandaloneKeyPackage("carol") + val addCarolResult = bob.addMember(carolBundle.keyPackage.toTlsBytes()) + + // Alice processes Bob's commit + alice.processCommit(addCarolResult.commitBytes, bob.leafIndex, ByteArray(0)) + + assertEquals(2L, alice.epoch) + assertEquals(2L, bob.epoch) + assertEquals(3, alice.memberCount) + assertEquals(3, bob.memberCount) + } + + // ----------------------------------------------------------------------- + // 6. External join via GroupInfo + // ----------------------------------------------------------------------- + + // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions + @Ignore + @Test + fun testExternalJoin_ZaraJoinsViaGroupInfo() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val groupInfoBytes = alice.groupInfo().toTlsBytes() + + // Zara joins externally + val (zara, commitBytes) = MlsGroup.externalJoin(groupInfoBytes, "zara".encodeToByteArray()) + assertEquals(1L, zara.epoch) + + // Alice processes the external commit + alice.processCommit(commitBytes, zara.leafIndex, ByteArray(0)) + assertEquals(1L, alice.epoch) + assertEquals(2, alice.memberCount) + + // They can now communicate + val msg = "External join works!".encodeToByteArray() + val ct = zara.encrypt(msg) + val dec = alice.decrypt(ct) + assertContentEquals(msg, dec.content) + } + + // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions + @Ignore + @Test + fun testExternalJoin_ExporterSecretsAgree() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val groupInfoBytes = alice.groupInfo().toTlsBytes() + + val (zara, commitBytes) = MlsGroup.externalJoin(groupInfoBytes, "zara".encodeToByteArray()) + alice.processCommit(commitBytes, zara.leafIndex, ByteArray(0)) + + val aliceKey = alice.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + val zaraKey = zara.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + assertContentEquals(aliceKey, zaraKey, "Exporter secrets must agree after external join") + } + + // ----------------------------------------------------------------------- + // 7. Member removal and re-keying + // ----------------------------------------------------------------------- + + @Test + fun testRemoveMember_EpochAdvancesAndKeysChange() { + // Alice creates group, adds Bob + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + val keyBeforeRemove = alice.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + + // Alice removes Bob + val removeResult = alice.removeMember(bob.leafIndex) + assertEquals(2L, alice.epoch) + assertEquals(1, alice.memberCount) + + // Key must change after removal (forward secrecy) + val keyAfterRemove = alice.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + assertFalse( + keyBeforeRemove.contentEquals(keyAfterRemove), + "Exporter secret must change after member removal for forward secrecy", + ) + } + + // ----------------------------------------------------------------------- + // 8. Signing key rotation (Update proposal) + // ----------------------------------------------------------------------- + + // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions + @Ignore + @Test + fun testSigningKeyRotation_EpochAdvances() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + val keyBefore = alice.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + + // Alice rotates her signing key + alice.proposeSigningKeyRotation() + val commitResult = alice.commit() + + // Bob processes Alice's rotation commit + bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0)) + + assertEquals(2L, alice.epoch) + assertEquals(2L, bob.epoch) + + // Exporter secrets must agree after rotation + val aliceKey = alice.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + val bobKey = bob.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + assertContentEquals(aliceKey, bobKey, "Exporter secrets must agree after signing key rotation") + + // Key changed from previous epoch + assertFalse(keyBefore.contentEquals(aliceKey), "Exporter secret should change after rotation") + } + + // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions + @Ignore + @Test + fun testEncryptDecryptAfterSigningKeyRotation() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + // Alice rotates her signing key + alice.proposeSigningKeyRotation() + val commitResult = alice.commit() + bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0)) + + // Both directions should still work after rotation + val msg1 = "After rotation from Alice".encodeToByteArray() + val ct1 = alice.encrypt(msg1) + val dec1 = bob.decrypt(ct1) + assertContentEquals(msg1, dec1.content) + + val msg2 = "After rotation from Bob".encodeToByteArray() + val ct2 = bob.encrypt(msg2) + val dec2 = alice.decrypt(ct2) + assertContentEquals(msg2, dec2.content) + } + + // ----------------------------------------------------------------------- + // 9. State persistence round-trip with lifecycle events + // ----------------------------------------------------------------------- + + @Test + fun testSaveRestoreAfterWelcome_CanStillDecrypt() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + // Save and restore Bob's state + val bobState = bob.saveState() + val bobRestored = MlsGroup.restore(bobState) + + // Restored Bob should be able to decrypt Alice's messages + val msg = "Can restored Bob read this?".encodeToByteArray() + val ct = alice.encrypt(msg) + val dec = bobRestored.decrypt(ct) + assertContentEquals(msg, dec.content) + } + + @Test + fun testSaveRestoreAfterWelcome_CanStillEncrypt() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + // Save and restore Bob + val bobState = bob.saveState() + val bobRestored = MlsGroup.restore(bobState) + + // Restored Bob should be able to encrypt for Alice + val msg = "Message from restored Bob".encodeToByteArray() + val ct = bobRestored.encrypt(msg) + val dec = alice.decrypt(ct) + assertContentEquals(msg, dec.content) + } + + // ----------------------------------------------------------------------- + // 10. PSK proposal: register and use in commit + // ----------------------------------------------------------------------- + + // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions + @Ignore + @Test + fun testPskProposal_EpochAdvancesWithPsk() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + // Register PSK on both sides + val pskId = "shared-secret-1".encodeToByteArray() + val pskValue = "super-secret-value-32-bytes-long".encodeToByteArray() + alice.registerPsk(pskId, pskValue) + bob.registerPsk(pskId, pskValue) + + val epochBefore = alice.epoch + + // Alice creates a PSK proposal and commits + alice.proposePsk(pskId) + val commitResult = alice.commit() + + // Bob processes the commit + bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0)) + + assertEquals(epochBefore + 1, alice.epoch) + assertEquals(alice.epoch, bob.epoch) + + // Communication still works after PSK injection + val msg = "Message after PSK".encodeToByteArray() + val ct = alice.encrypt(msg) + val dec = bob.decrypt(ct) + assertContentEquals(msg, dec.content) + } + + // ----------------------------------------------------------------------- + // 11. ReInit proposal: marks group for reinitialization + // ----------------------------------------------------------------------- + + @Test + fun testReInitProposal_MarksGroupForReInit() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + // Alice proposes ReInit + alice.proposeReInit() + val commitResult = alice.commit() + + // After commit, Alice's group should be marked as reInit pending + assertNotNull(alice.reInitPending, "ReInit should be pending after commit") + + // Bob processes and should also see reInit + bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0)) + assertNotNull(bob.reInitPending, "Bob should also see ReInit pending") + } + + // ----------------------------------------------------------------------- + // 12. Empty commit (no proposals, just UpdatePath for forward secrecy) + // ----------------------------------------------------------------------- + + // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions + @Ignore + @Test + fun testEmptyCommit_AdvancesEpoch() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = createStandaloneKeyPackage("bob") + val addResult = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + val epochBefore = alice.epoch + + // Alice commits with no proposals (purely for forward secrecy / UpdatePath) + val commitResult = alice.commit() + bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0)) + + assertEquals(epochBefore + 1, alice.epoch) + assertEquals(alice.epoch, bob.epoch) + + // Exporter secrets still agree + val aliceKey = alice.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + val bobKey = bob.exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + assertContentEquals(aliceKey, bobKey) + } +} 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..81a0bf648 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupTest.kt @@ -0,0 +1,292 @@ +/* + * 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 testExternalJoin() { + // Alice creates a group + val alice = MlsGroup.create("alice".encodeToByteArray()) + assertEquals(0L, alice.epoch) + assertEquals(1, alice.memberCount) + + // Alice publishes GroupInfo for external joiners + val groupInfoBytes = alice.groupInfo().toTlsBytes() + + // Zara joins via external commit (without a Welcome) + val (zara, commitBytes) = + MlsGroup.externalJoin( + groupInfoBytes, + "zara".encodeToByteArray(), + ) + + // Zara is now in the group at epoch 1 + assertEquals(1L, zara.epoch) + + // Alice processes Zara's external commit + alice.processCommit(commitBytes, zara.leafIndex, ByteArray(0)) + assertEquals(1L, alice.epoch) + assertEquals(2, alice.memberCount) + } + + @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) + } +}