diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/CommitOrderingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/CommitOrderingTest.kt new file mode 100644 index 000000000..5e337cc56 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/CommitOrderingTest.kt @@ -0,0 +1,185 @@ +/* + * 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.CommitOrdering +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for deterministic commit conflict resolution (MIP-03). + */ +class CommitOrderingTest { + private val groupId = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + + private fun makeGroupEvent( + id: String, + createdAt: Long, + ): GroupEvent = + GroupEvent( + id = id.padEnd(64, '0'), + pubKey = "a".repeat(64), + createdAt = createdAt, + tags = arrayOf(arrayOf("h", groupId)), + content = "encrypted", + sig = "s".repeat(128), + ) + + // ===== selectWinner ===== + + @Test + fun testSelectWinner_EmptyList() { + assertNull(CommitOrdering.selectWinner(emptyList())) + } + + @Test + fun testSelectWinner_SingleCommit() { + val commit = makeGroupEvent("aaa", 1000) + assertEquals(commit, CommitOrdering.selectWinner(listOf(commit))) + } + + @Test + fun testSelectWinner_LowestTimestampWins() { + val early = makeGroupEvent("bbb", 1000) + val late = makeGroupEvent("aaa", 2000) + + // Early wins even though its id is "larger" + assertEquals(early, CommitOrdering.selectWinner(listOf(late, early))) + assertEquals(early, CommitOrdering.selectWinner(listOf(early, late))) + } + + @Test + fun testSelectWinner_SameTimestamp_SmallestIdWins() { + val smallId = makeGroupEvent("111", 1000) // id starts with 1 + val largeId = makeGroupEvent("fff", 1000) // id starts with f + + assertEquals(smallId, CommitOrdering.selectWinner(listOf(largeId, smallId))) + assertEquals(smallId, CommitOrdering.selectWinner(listOf(smallId, largeId))) + } + + @Test + fun testSelectWinner_ThreeCompetitors() { + val a = makeGroupEvent("ccc", 1000) + val b = makeGroupEvent("aaa", 1000) // Same timestamp, smallest id + val c = makeGroupEvent("bbb", 999) // Earliest timestamp + + // c wins (earliest timestamp) + assertEquals(c, CommitOrdering.selectWinner(listOf(a, b, c))) + } + + // ===== isWinner ===== + + @Test + fun testIsWinner() { + val winner = makeGroupEvent("aaa", 999) + val loser = makeGroupEvent("bbb", 1000) + val competitors = listOf(winner, loser) + + assertTrue(CommitOrdering.isWinner(winner, competitors)) + assertFalse(CommitOrdering.isWinner(loser, competitors)) + } + + @Test + fun testIsWinner_EmptyCompetitors() { + val commit = makeGroupEvent("aaa", 1000) + assertFalse(CommitOrdering.isWinner(commit, emptyList())) + } + + // ===== comparator ordering ===== + + @Test + fun testComparatorSortsCorrectly() { + val events = + listOf( + makeGroupEvent("ccc", 3000), + makeGroupEvent("aaa", 1000), + makeGroupEvent("bbb", 1000), + makeGroupEvent("ddd", 2000), + ) + + val sorted = events.sortedWith(CommitOrdering.comparator) + + // 1. aaa@1000 (lowest timestamp, then smallest id) + // 2. bbb@1000 (same timestamp, next id) + // 3. ddd@2000 + // 4. ccc@3000 + assertEquals("aaa", sorted[0].id.take(3)) + assertEquals("bbb", sorted[1].id.take(3)) + assertEquals("ddd", sorted[2].id.take(3)) + assertEquals("ccc", sorted[3].id.take(3)) + } + + // ===== EpochCommitTracker ===== + + @Test + fun testEpochCommitTracker_Basic() { + val tracker = CommitOrdering.EpochCommitTracker() + val epoch1Commit1 = makeGroupEvent("bbb", 1000) + val epoch1Commit2 = makeGroupEvent("aaa", 1001) + + tracker.addCommit(1L, epoch1Commit1) + tracker.addCommit(1L, epoch1Commit2) + + assertEquals(2, tracker.pendingForEpoch(1L).size) + assertEquals(0, tracker.pendingForEpoch(2L).size) + + // Resolve: epoch1Commit1 wins (earlier timestamp) + val winner = tracker.resolve(1L) + assertEquals(epoch1Commit1, winner) + } + + @Test + fun testEpochCommitTracker_MultipleEpochs() { + val tracker = CommitOrdering.EpochCommitTracker() + val e1 = makeGroupEvent("aaa", 1000) + val e2 = makeGroupEvent("bbb", 2000) + + tracker.addCommit(1L, e1) + tracker.addCommit(2L, e2) + + assertEquals(setOf(1L, 2L), tracker.pendingEpochs()) + + tracker.clearEpoch(1L) + assertEquals(setOf(2L), tracker.pendingEpochs()) + } + + @Test + fun testEpochCommitTracker_ClearAll() { + val tracker = CommitOrdering.EpochCommitTracker() + tracker.addCommit(1L, makeGroupEvent("aaa", 1000)) + tracker.addCommit(2L, makeGroupEvent("bbb", 2000)) + + tracker.clear() + + assertTrue(tracker.pendingEpochs().isEmpty()) + assertNull(tracker.resolve(1L)) + } + + @Test + fun testEpochCommitTracker_ResolveEmpty() { + val tracker = CommitOrdering.EpochCommitTracker() + assertNull(tracker.resolve(999L)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/GroupEventEncryptionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/GroupEventEncryptionTest.kt new file mode 100644 index 000000000..20ef6bc01 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/GroupEventEncryptionTest.kt @@ -0,0 +1,162 @@ +/* + * 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.marmot.mip03GroupMessages.GroupEventEncryption +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Tests for Marmot GroupEvent encryption/decryption (MIP-03). + */ +class GroupEventEncryptionTest { + private fun hex(s: String): ByteArray = s.replace(" ", "").hexToByteArray() + + // Simulate a 32-byte MLS exporter-derived key + private val testGroupKey = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + + @Test + fun testRoundTrip() { + val mlsMessage = "Hello MLS Group!".encodeToByteArray() + + val encrypted = GroupEventEncryption.encrypt(mlsMessage, testGroupKey) + val decrypted = GroupEventEncryption.decrypt(encrypted, testGroupKey) + + assertContentEquals(mlsMessage, decrypted) + } + + @Test + fun testRoundTrip_EmptyMessage() { + val mlsMessage = ByteArray(0) + + val encrypted = GroupEventEncryption.encrypt(mlsMessage, testGroupKey) + val decrypted = GroupEventEncryption.decrypt(encrypted, testGroupKey) + + assertContentEquals(mlsMessage, decrypted) + } + + @Test + fun testRoundTrip_LargeMessage() { + // Simulate a large MLS commit message + val mlsMessage = ByteArray(4096) { (it % 256).toByte() } + + val encrypted = GroupEventEncryption.encrypt(mlsMessage, testGroupKey) + val decrypted = GroupEventEncryption.decrypt(encrypted, testGroupKey) + + assertContentEquals(mlsMessage, decrypted) + } + + @OptIn(ExperimentalEncodingApi::class) + @Test + fun testEncryptedFormat() { + val mlsMessage = "test content".encodeToByteArray() + + val encryptedBase64 = GroupEventEncryption.encrypt(mlsMessage, testGroupKey) + val decoded = Base64.decode(encryptedBase64) + + // Should be: nonce(12) + ciphertext(messageLen) + tag(16) + val expectedSize = GroupEvent.NONCE_LENGTH + mlsMessage.size + GroupEvent.AUTH_TAG_LENGTH + assertEquals(expectedSize, decoded.size) + } + + @Test + fun testDifferentEncryptionsProduceDifferentCiphertext() { + val mlsMessage = "same plaintext".encodeToByteArray() + + val encrypted1 = GroupEventEncryption.encrypt(mlsMessage, testGroupKey) + val encrypted2 = GroupEventEncryption.encrypt(mlsMessage, testGroupKey) + + // Each encryption uses a random nonce, so outputs must differ + assertTrue(encrypted1 != encrypted2) + } + + @Test + fun testWrongKeyFails() { + val mlsMessage = "secret message".encodeToByteArray() + val wrongKey = hex("ff0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + + val encrypted = GroupEventEncryption.encrypt(mlsMessage, testGroupKey) + + assertFailsWith { + GroupEventEncryption.decrypt(encrypted, wrongKey) + } + } + + @Test + fun testInvalidKeyLength() { + val mlsMessage = "test".encodeToByteArray() + val shortKey = ByteArray(16) + + assertFailsWith { + GroupEventEncryption.encrypt(mlsMessage, shortKey) + } + } + + @OptIn(ExperimentalEncodingApi::class) + @Test + fun testTruncatedPayloadFails() { + val tooShort = Base64.encode(ByteArray(10)) // Less than MIN_CONTENT_LENGTH + + assertFailsWith { + GroupEventEncryption.decrypt(tooShort, testGroupKey) + } + } + + @OptIn(ExperimentalEncodingApi::class) + @Test + fun testTamperedContentFails() { + val mlsMessage = "tamper test".encodeToByteArray() + + val encryptedBase64 = GroupEventEncryption.encrypt(mlsMessage, testGroupKey) + val decoded = Base64.decode(encryptedBase64) + + // Tamper with a ciphertext byte (after the 12-byte nonce) + decoded[GroupEvent.NONCE_LENGTH] = (decoded[GroupEvent.NONCE_LENGTH].toInt() xor 0xFF).toByte() + val tamperedBase64 = Base64.encode(decoded) + + assertFailsWith { + GroupEventEncryption.decrypt(tamperedBase64, testGroupKey) + } + } + + @Test + fun testIntegrationWithGroupEventBuild() { + val mlsMessage = "MLS application message".encodeToByteArray() + val groupId = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + + val encryptedContent = GroupEventEncryption.encrypt(mlsMessage, testGroupKey) + val groupEvent = GroupEvent.build(encryptedContent, groupId) + + assertEquals(GroupEvent.KIND, groupEvent.kind) + assertEquals(encryptedContent, groupEvent.content) + + // Decrypt from the event content + val decrypted = GroupEventEncryption.decrypt(groupEvent.content, testGroupKey) + assertContentEquals(mlsMessage, decrypted) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/KeyPackageUtilsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/KeyPackageUtilsTest.kt new file mode 100644 index 000000000..e7d7e0e59 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/KeyPackageUtilsTest.kt @@ -0,0 +1,218 @@ +/* + * 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.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils +import com.vitorpamplona.quartz.nip01Core.core.Event +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 KeyPackage lifecycle helpers (MIP-00). + */ +class KeyPackageUtilsTest { + private val testPubKey = "a".repeat(64) + private val testRef = "b".repeat(64) + + private fun makeKeyPackageEvent( + dTag: String = "0", + createdAt: Long = 1000, + encoding: String = "base64", + ciphersuite: String = "0x0001", + keyPackageRef: String = testRef, + content: String = "dGVzdA==", // base64("test") + ): KeyPackageEvent = + KeyPackageEvent( + id = "e".repeat(64), + pubKey = testPubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("d", dTag), + arrayOf("encoding", encoding), + arrayOf("mls_ciphersuite", ciphersuite), + arrayOf("i", keyPackageRef), + arrayOf("mls_protocol_version", "1.0"), + arrayOf("mls_extensions", "0xf2ee", "0x000a"), + arrayOf("mls_proposals", "0x000a"), + arrayOf("relays", "wss://relay.example.com"), + ), + content = content, + sig = "s".repeat(128), + ) + + // ===== isValid ===== + + @Test + fun testIsValid_ValidKeyPackage() { + val kp = makeKeyPackageEvent() + assertTrue(KeyPackageUtils.isValid(kp)) + } + + @Test + fun testIsValid_WrongEncoding() { + val kp = makeKeyPackageEvent(encoding = "raw") + assertFalse(KeyPackageUtils.isValid(kp)) + } + + @Test + fun testIsValid_EmptyContent() { + val kp = makeKeyPackageEvent(content = "") + assertFalse(KeyPackageUtils.isValid(kp)) + } + + @Test + fun testIsValid_MissingRef() { + val kp = makeKeyPackageEvent(keyPackageRef = "") + assertFalse(KeyPackageUtils.isValid(kp)) + } + + // ===== selectBest ===== + + @Test + fun testSelectBest_EmptyList() { + assertNull(KeyPackageUtils.selectBest(emptyList())) + } + + @Test + fun testSelectBest_SingleValid() { + val kp = makeKeyPackageEvent() + assertEquals(kp, KeyPackageUtils.selectBest(listOf(kp))) + } + + @Test + fun testSelectBest_PrefersNewest() { + val old = makeKeyPackageEvent(dTag = "0", createdAt = 1000) + val newer = makeKeyPackageEvent(dTag = "1", createdAt = 2000) + + val best = KeyPackageUtils.selectBest(listOf(old, newer)) + assertNotNull(best) + assertEquals(2000, best.createdAt) + } + + @Test + fun testSelectBest_PrefersNonLastResort() { + val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000) + val regular = makeKeyPackageEvent(dTag = "0", createdAt = 1000) + + // Even though lastResort is newer, regular is preferred + val best = KeyPackageUtils.selectBest(listOf(lastResort, regular), lastResortDTag = "lr") + assertNotNull(best) + assertEquals("0", best.dTag()) + } + + @Test + fun testSelectBest_FallsBackToLastResort() { + val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000) + + // Only last-resort available + val best = KeyPackageUtils.selectBest(listOf(lastResort), lastResortDTag = "lr") + assertNotNull(best) + assertEquals("lr", best.dTag()) + } + + @Test + fun testSelectBest_FiltersOutInvalid() { + val invalid = makeKeyPackageEvent(dTag = "0", encoding = "raw") + val valid = makeKeyPackageEvent(dTag = "1", createdAt = 500) + + val best = KeyPackageUtils.selectBest(listOf(invalid, valid)) + assertNotNull(best) + assertEquals("1", best.dTag()) + } + + @Test + fun testSelectBest_AllInvalid() { + val invalid1 = makeKeyPackageEvent(encoding = "raw") + val invalid2 = makeKeyPackageEvent(content = "") + + assertNull(KeyPackageUtils.selectBest(listOf(invalid1, invalid2))) + } + + // ===== buildRotation ===== + + @Test + fun testBuildRotation() { + val template = + KeyPackageUtils.buildRotation( + newKeyPackageBase64 = "bmV3IGtleXBhY2thZ2U=", + dTagSlot = "0", + newKeyPackageRef = testRef, + relays = emptyList(), + ) + + assertEquals(KeyPackageEvent.KIND, template.kind) + assertEquals("bmV3IGtleXBhY2thZ2U=", template.content) + } + + // ===== migration helpers ===== + + @Test + fun testIsKeyPackageKind() { + val addressable = + Event( + id = "e".repeat(64), + pubKey = testPubKey, + createdAt = 1000, + kind = 30443, + tags = emptyArray(), + content = "", + sig = "", + ) + val legacy = + Event( + id = "e".repeat(64), + pubKey = testPubKey, + createdAt = 1000, + kind = 443, + tags = emptyArray(), + content = "", + sig = "", + ) + val other = + Event( + id = "e".repeat(64), + pubKey = testPubKey, + createdAt = 1000, + kind = 1, + tags = emptyArray(), + content = "", + sig = "", + ) + + assertTrue(KeyPackageUtils.isKeyPackageKind(addressable)) + assertTrue(KeyPackageUtils.isKeyPackageKind(legacy)) + assertFalse(KeyPackageUtils.isKeyPackageKind(other)) + } + + @Test + fun testMigrationKinds() { + val kinds = KeyPackageUtils.migrationKinds() + assertTrue(kinds.contains(30443)) + assertTrue(kinds.contains(443)) + assertEquals(2, kinds.size) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotFiltersTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotFiltersTest.kt new file mode 100644 index 000000000..86af1d4ae --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotFiltersTest.kt @@ -0,0 +1,142 @@ +/* + * 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.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for Marmot relay subscription filter builders. + */ +class MarmotFiltersTest { + private val testPubKey = "a".repeat(64) + private val testGroupId = "b".repeat(64) + private val testRef = "c".repeat(64) + + @Test + fun testKeyPackagesByAuthor() { + val filter = MarmotFilters.keyPackagesByAuthor(testPubKey) + + assertEquals(listOf(KeyPackageEvent.KIND), filter.kinds) + assertEquals(listOf(testPubKey), filter.authors) + assertNull(filter.tags) + assertNull(filter.since) + } + + @Test + fun testKeyPackagesByAuthors() { + val pubkeys = listOf("a".repeat(64), "b".repeat(64)) + val filter = MarmotFilters.keyPackagesByAuthors(pubkeys) + + assertEquals(listOf(KeyPackageEvent.KIND), filter.kinds) + assertEquals(pubkeys, filter.authors) + } + + @Test + fun testKeyPackageByRef() { + val filter = MarmotFilters.keyPackageByRef(testRef) + + assertEquals(listOf(KeyPackageEvent.KIND), filter.kinds) + assertNull(filter.authors) + val tags = filter.tags + assertNotNull(tags) + assertEquals(listOf(testRef), tags["i"]) + } + + @Test + fun testGroupEventsByGroupId() { + val filter = MarmotFilters.groupEventsByGroupId(testGroupId) + + assertEquals(listOf(GroupEvent.KIND), filter.kinds) + val tags = filter.tags + assertNotNull(tags) + assertEquals(listOf(testGroupId), tags["h"]) + assertNull(filter.since) + } + + @Test + fun testGroupEventsByGroupIdSince() { + val since = 1700000000L + val filter = MarmotFilters.groupEventsByGroupIdSince(testGroupId, since) + + assertEquals(listOf(GroupEvent.KIND), filter.kinds) + val tags = filter.tags + assertNotNull(tags) + assertEquals(listOf(testGroupId), tags["h"]) + assertEquals(since, filter.since) + } + + @Test + fun testGiftWrapsForUser() { + val filter = MarmotFilters.giftWrapsForUser(testPubKey) + + assertEquals(listOf(GiftWrapEvent.KIND), filter.kinds) + val tags = filter.tags + assertNotNull(tags) + assertEquals(listOf(testPubKey), tags["p"]) + } + + @Test + fun testGiftWrapsForUserSince() { + val since = 1700000000L + val filter = MarmotFilters.giftWrapsForUserSince(testPubKey, since) + + assertEquals(listOf(GiftWrapEvent.KIND), filter.kinds) + val tags = filter.tags + assertNotNull(tags) + assertEquals(listOf(testPubKey), tags["p"]) + assertEquals(since, filter.since) + } + + @Test + fun testKeyPackagesMigration() { + val filter = MarmotFilters.keyPackagesMigration(testPubKey) + + val kinds = filter.kinds + assertNotNull(kinds) + assertTrue(kinds.contains(KeyPackageEvent.KIND)) + assertTrue(kinds.contains(443)) + assertEquals(listOf(testPubKey), filter.authors) + } + + @Test + fun testFiltersAreNotEmpty() { + // None of the filter builders should produce empty filters + val filters = + listOf( + MarmotFilters.keyPackagesByAuthor(testPubKey), + MarmotFilters.keyPackageByRef(testRef), + MarmotFilters.groupEventsByGroupId(testGroupId), + MarmotFilters.giftWrapsForUser(testPubKey), + MarmotFilters.keyPackagesMigration(testPubKey), + ) + + filters.forEach { filter -> + assertTrue(!filter.isEmpty(), "Filter should not be empty: $filter") + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Poly1305Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Poly1305Test.kt new file mode 100644 index 000000000..f82804b66 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Poly1305Test.kt @@ -0,0 +1,214 @@ +/* + * 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.nip44Encryption.crypto + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * Tests for standard ChaCha20-Poly1305 AEAD (RFC 8439) with 12-byte nonces. + * Includes the official RFC 8439 §2.8.2 test vector and round-trip tests. + */ +class ChaCha20Poly1305Test { + private fun hex(s: String): ByteArray = s.replace(" ", "").hexToByteArray() + + // ===== RFC 8439 §2.8.2: AEAD_CHACHA20_POLY1305 Test Vector ===== + @Test + fun testEncrypt_RFC8439() { + val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f") + val nonce = hex("070000004041424344454647") + val ad = hex("50515253c0c1c2c3c4c5c6c7") + val plaintext = + "Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it." + .encodeToByteArray() + + val result = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key) + + val expectedCiphertext = + hex( + "d31a8d34648e60db7b86afbc53ef7ec2" + + "a4aded51296e08fea9e2b5a736ee62d6" + + "3dbea45e8ca9671282fafb69da92728b" + + "1a71de0a9e060b2905d6a5b67ecd3b36" + + "92ddbd7f2d778b8c9803aee328091b58" + + "fab324e4fad675945585808b4831d7bc" + + "3ff4def08e4b7a9de576d26586cec64b" + + "6116", + ) + val expectedTag = hex("1ae10b594f09e26a7e902ecbd0600691") + + val ciphertext = result.copyOfRange(0, result.size - 16) + val tag = result.copyOfRange(result.size - 16, result.size) + + assertContentEquals(expectedCiphertext, ciphertext) + assertContentEquals(expectedTag, tag) + } + + @Test + fun testDecrypt_RFC8439() { + val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f") + val nonce = hex("070000004041424344454647") + val ad = hex("50515253c0c1c2c3c4c5c6c7") + val ciphertextWithTag = + hex( + "d31a8d34648e60db7b86afbc53ef7ec2" + + "a4aded51296e08fea9e2b5a736ee62d6" + + "3dbea45e8ca9671282fafb69da92728b" + + "1a71de0a9e060b2905d6a5b67ecd3b36" + + "92ddbd7f2d778b8c9803aee328091b58" + + "fab324e4fad675945585808b4831d7bc" + + "3ff4def08e4b7a9de576d26586cec64b" + + "6116" + + "1ae10b594f09e26a7e902ecbd0600691", + ) + + val plaintext = ChaCha20Poly1305.decrypt(ciphertextWithTag, ad, nonce, key) + + val expected = + "Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it." + assertEquals(expected, plaintext.decodeToString()) + } + + @Test + fun testRoundTrip() { + val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + val nonce = hex("000000000000004a00000000") + val ad = hex("feedfacedeadbeef") + val plaintext = "Hello, Nostr! Round-trip test with 12-byte nonce.".encodeToByteArray() + + val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key) + val decrypted = ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun testRoundTrip_EmptyAD() { + val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + val nonce = hex("000102030405060708090a0b") + val ad = ByteArray(0) + val plaintext = "Empty AAD test - used by Marmot GroupEvents".encodeToByteArray() + + val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key) + val decrypted = ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key) + + assertContentEquals(plaintext, decrypted) + } + + @Test + fun testRoundTrip_EmptyPlaintext() { + val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + val nonce = hex("000102030405060708090a0b") + val ad = ByteArray(0) + val plaintext = ByteArray(0) + + val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key) + assertEquals(16, encrypted.size) // Just the 16-byte tag + val decrypted = ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key) + assertContentEquals(plaintext, decrypted) + } + + @Test + fun testTamperedCiphertext() { + val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + val nonce = hex("000102030405060708090a0b") + val ad = ByteArray(0) + val plaintext = "Tamper detection test".encodeToByteArray() + + val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key) + encrypted[0] = (encrypted[0].toInt() xor 1).toByte() + + assertFailsWith { + ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key) + } + } + + @Test + fun testTamperedTag() { + val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + val nonce = hex("000102030405060708090a0b") + val ad = ByteArray(0) + val plaintext = "Tag tamper test".encodeToByteArray() + + val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key) + // Flip a bit in the last byte (part of the tag) + encrypted[encrypted.size - 1] = (encrypted[encrypted.size - 1].toInt() xor 1).toByte() + + assertFailsWith { + ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key) + } + } + + @Test + fun testWrongKey() { + val key1 = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + val key2 = hex("ff0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + val nonce = hex("000102030405060708090a0b") + val ad = ByteArray(0) + val plaintext = "Wrong key test".encodeToByteArray() + + val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key1) + + assertFailsWith { + ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key2) + } + } + + @Test + fun testInvalidNonceLength() { + val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + val badNonce = hex("0102030405060708") // 8 bytes instead of 12 + val plaintext = "test".encodeToByteArray() + + assertFailsWith { + ChaCha20Poly1305.encrypt(plaintext, ByteArray(0), badNonce, key) + } + } + + @Test + fun testInvalidKeyLength() { + val badKey = hex("0102030405060708090a0b0c0d0e0f10") // 16 bytes instead of 32 + val nonce = hex("000102030405060708090a0b") + val plaintext = "test".encodeToByteArray() + + assertFailsWith { + ChaCha20Poly1305.encrypt(plaintext, ByteArray(0), nonce, badKey) + } + } + + @Test + fun testLargeMessage() { + val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + val nonce = hex("000102030405060708090a0b") + val ad = ByteArray(0) + // Test with a message larger than a single ChaCha20 block (64 bytes) + val plaintext = ByteArray(1000) { (it % 256).toByte() } + + val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key) + assertEquals(1000 + 16, encrypted.size) + val decrypted = ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key) + + assertContentEquals(plaintext, decrypted) + } +}