diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotFilters.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotFilters.kt new file mode 100644 index 0000000000..75cf73c476 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotFilters.kt @@ -0,0 +1,141 @@ +/* + * 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.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * Relay subscription filter builders for Marmot protocol events. + * + * Provides pre-configured [Filter] instances for subscribing to the various + * Marmot event types on Nostr relays. + */ +object MarmotFilters { + /** + * Filter for KeyPackages by author (kind:30443). + * Used to discover a user's available KeyPackages for group invitations. + * + * {kinds: [30443], authors: [pubkey]} + */ + fun keyPackagesByAuthor(pubkey: HexKey): Filter = + Filter( + kinds = listOf(KeyPackageEvent.KIND), + authors = listOf(pubkey), + ) + + /** + * Filter for KeyPackages by multiple authors. + * Used when inviting multiple users to a group at once. + * + * {kinds: [30443], authors: [pubkey1, pubkey2, ...]} + */ + fun keyPackagesByAuthors(pubkeys: List): Filter = + Filter( + kinds = listOf(KeyPackageEvent.KIND), + authors = pubkeys, + ) + + /** + * Filter for a specific KeyPackage by its ref (kind:30443, #i tag). + * Used to look up a specific KeyPackage by its KeyPackageRef hash. + * + * {kinds: [30443], #i: [keyPackageRef]} + */ + fun keyPackageByRef(keyPackageRef: HexKey): Filter = + Filter( + kinds = listOf(KeyPackageEvent.KIND), + tags = mapOf("i" to listOf(keyPackageRef)), + ) + + /** + * Filter for GroupEvents by group ID (kind:445, #h tag). + * Used to subscribe to all messages and commits for a specific group. + * + * {kinds: [445], #h: [nostrGroupId]} + */ + fun groupEventsByGroupId(nostrGroupId: HexKey): Filter = + Filter( + kinds = listOf(GroupEvent.KIND), + tags = mapOf("h" to listOf(nostrGroupId)), + ) + + /** + * Filter for GroupEvents by group ID with a time range. + * Used to catch up on missed messages since a given timestamp. + * + * {kinds: [445], #h: [nostrGroupId], since: timestamp} + */ + fun groupEventsByGroupIdSince( + nostrGroupId: HexKey, + since: Long, + ): Filter = + Filter( + kinds = listOf(GroupEvent.KIND), + tags = mapOf("h" to listOf(nostrGroupId)), + since = since, + ) + + /** + * Filter for NIP-59 gift wraps addressed to a user (kind:1059). + * Welcome messages (kind:444) are delivered inside gift wraps. + * This reuses the standard NIP-59 subscription pattern. + * + * {kinds: [1059], #p: [recipientPubKey]} + */ + fun giftWrapsForUser(recipientPubKey: HexKey): Filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(recipientPubKey)), + ) + + /** + * Filter for NIP-59 gift wraps since a given timestamp. + * Used to catch up on missed Welcome messages. + * + * {kinds: [1059], #p: [recipientPubKey], since: timestamp} + */ + fun giftWrapsForUserSince( + recipientPubKey: HexKey, + since: Long, + ): Filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(recipientPubKey)), + since = since, + ) + + /** + * Filter for KeyPackages during migration (both kind:443 and kind:30443). + * Used during the transition period from legacy to addressable KeyPackages. + * + * {kinds: [30443, 443], authors: [pubkey]} + */ + fun keyPackagesMigration(pubkey: HexKey): Filter = + Filter( + kinds = KeyPackageUtils.migrationKinds(), + authors = listOf(pubkey), + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt new file mode 100644 index 0000000000..c4522057c9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt @@ -0,0 +1,127 @@ +/* + * 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.mip00KeyPackages + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate + +/** + * Utility functions for KeyPackage lifecycle management (MIP-00). + * + * Covers: + * - Selection policy: prefer non-last-resort, highest created_at, validate encoding + * - Rotation: publish new kind:30443 under same d-tag after joining a group + * - Migration: support both kind:443 (legacy) and kind:30443 (addressable) during transition + */ +object KeyPackageUtils { + /** Legacy non-addressable KeyPackage kind (pre-migration) */ + const val LEGACY_KIND = 443 + + /** Current addressable KeyPackage kind */ + const val CURRENT_KIND = KeyPackageEvent.KIND // 30443 + + /** Default d-tag slot for primary KeyPackage */ + const val PRIMARY_SLOT = "0" + + /** Maximum number of d-tag slots a user should maintain */ + const val MAX_SLOTS = 10 + + /** + * Selects the best KeyPackage from a set of candidates for a given user. + * + * Selection policy (MIP-00): + * 1. Filter out invalid KeyPackages (bad encoding, missing required fields) + * 2. Prefer non-last-resort KeyPackages (those with multiple slots available) + * 3. Among valid candidates, prefer highest created_at (most recent) + * + * @param candidates list of KeyPackageEvents to choose from + * @param lastResortDTag d-tag of the user's last-resort KeyPackage (if known) + * @return the best KeyPackage, or null if no valid candidates + */ + fun selectBest( + candidates: List, + lastResortDTag: String? = null, + ): KeyPackageEvent? { + val valid = candidates.filter { isValid(it) } + if (valid.isEmpty()) return null + + // Prefer non-last-resort KeyPackages + val nonLastResort = valid.filter { it.dTag() != lastResortDTag } + val pool = nonLastResort.ifEmpty { valid } + + // Select the most recent + return pool.maxByOrNull { it.createdAt } + } + + /** + * Validates a KeyPackage event has required fields and proper encoding. + */ + fun isValid(event: KeyPackageEvent): Boolean = + event.encoding() == EncodingTag.BASE64 && + event.content.isNotEmpty() && + !event.keyPackageRef().isNullOrEmpty() && + !event.mlsCiphersuite().isNullOrEmpty() + + /** + * Builds a rotated KeyPackage for the same d-tag slot. + * + * After joining a group (processing a Welcome), a member MUST rotate their + * KeyPackage to prevent reuse of the consumed init_key material. + * + * @param newKeyPackageBase64 the new MLS KeyPackage content + * @param dTagSlot the d-tag slot to rotate (reuses the same slot) + * @param newKeyPackageRef the KeyPackageRef of the new KeyPackage + * @param relays relay URLs where this KeyPackage should be published + * @param ciphersuite MLS ciphersuite identifier + * @param clientName optional client name + * @return a new KeyPackageEvent that replaces the old one at the same d-tag + */ + fun buildRotation( + newKeyPackageBase64: String, + dTagSlot: String, + newKeyPackageRef: HexKey, + relays: List, + ciphersuite: String = "0x0001", + clientName: String? = null, + ): EventTemplate = + KeyPackageEvent.build( + keyPackageBase64 = newKeyPackageBase64, + dTagSlot = dTagSlot, + keyPackageRef = newKeyPackageRef, + relays = relays, + ciphersuite = ciphersuite, + clientName = clientName, + ) + + /** + * Checks whether an event is a KeyPackage (either legacy kind:443 or current kind:30443). + * Used during the migration period. + */ + fun isKeyPackageKind(event: Event): Boolean = event.kind == CURRENT_KIND || event.kind == LEGACY_KIND + + /** + * Returns the list of kinds to query during migration (both legacy and current). + */ + fun migrationKinds(): List = listOf(CURRENT_KIND, LEGACY_KIND) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt new file mode 100644 index 0000000000..41c56b8e17 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt @@ -0,0 +1,87 @@ +/* + * 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.mip02Welcome + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Composes the NIP-59 gift wrap pipeline for Marmot Welcome messages (MIP-02). + * + * The delivery flow: + * WelcomeEvent (unsigned rumor, kind:444) + * → SealedRumorEvent (kind:13, encrypted with sender's key) + * → GiftWrapEvent (kind:1059, encrypted with ephemeral key to recipient) + * + * CRITICAL: The Commit that adds the new member MUST be confirmed by relays + * BEFORE calling [wrapForRecipient], to prevent MLS state forks. + */ +object WelcomeGiftWrap { + /** + * Wraps a WelcomeEvent through the full NIP-59 gift wrap pipeline. + * + * @param welcomeBase64 base64-encoded MLS Welcome message + * @param keyPackageEventId event ID of the KeyPackage consumed for this invitation + * @param relays relays where the new member should look for GroupEvents + * @param recipientPubKey public key of the new member being invited + * @param signer the sender's signer (used to seal the rumor) + * @param createdAt timestamp for the welcome event (defaults to now) + * @return GiftWrapEvent ready to publish to relays + */ + suspend fun wrapForRecipient( + welcomeBase64: String, + keyPackageEventId: HexKey, + relays: List, + recipientPubKey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GiftWrapEvent { + // Step 1: Build the WelcomeEvent template and sign it + val welcomeTemplate = + WelcomeEvent.build( + welcomeBase64 = welcomeBase64, + keyPackageEventId = keyPackageEventId, + relays = relays, + createdAt = createdAt, + ) + val welcomeEvent: WelcomeEvent = signer.sign(welcomeTemplate) + + // Step 2: Create a Rumor from the signed event and seal it (kind:13) + val rumor = Rumor.create(welcomeEvent) + val sealedRumor = + SealedRumorEvent.create( + rumor = rumor, + encryptTo = recipientPubKey, + signer = signer, + ) + + // Step 3: Gift wrap (kind:1059) with an ephemeral key to the recipient + return GiftWrapEvent.create( + event = sealedRumor, + recipientPubKey = recipientPubKey, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/CommitOrdering.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/CommitOrdering.kt new file mode 100644 index 0000000000..614979a782 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/CommitOrdering.kt @@ -0,0 +1,123 @@ +/* + * 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.mip03GroupMessages + +/** + * Deterministic commit conflict resolution for MLS over Nostr (MIP-03). + * + * When multiple members submit Commits for the same epoch, exactly one must win: + * 1. Lowest created_at timestamp wins + * 2. If timestamps are equal, lexicographically smallest event id wins + * 3. All other competing Commits for that epoch are discarded + * + * This ensures all group members converge on the same group state without + * requiring a central coordinator. + */ +object CommitOrdering { + /** + * Comparator for ordering GroupEvents deterministically. + * The "winner" (lowest value) should be applied; all others are discarded. + */ + val comparator: Comparator = + compareBy { it.createdAt } + .thenBy { it.id } + + /** + * Selects the winning Commit from a set of competing Commits for the same epoch. + * + * @param commits competing Commits for the same MLS epoch + * @return the winning Commit that should be applied, or null if empty + */ + fun selectWinner(commits: List): GroupEvent? { + if (commits.isEmpty()) return null + return commits.minWithOrNull(comparator) + } + + /** + * Determines if a given commit is the winner among competing commits. + * + * @param candidate the commit to check + * @param competitors all competing commits for the same epoch (including candidate) + * @return true if candidate is the winning commit + */ + fun isWinner( + candidate: GroupEvent, + competitors: List, + ): Boolean { + val winner = selectWinner(competitors) ?: return false + return winner.id == candidate.id + } + + /** + * Tracks pending commits per epoch and resolves conflicts. + * + * Accumulate commits as they arrive from relays, then call [resolve] + * to determine which commit wins for each epoch. + */ + class EpochCommitTracker { + private val pendingByEpoch = mutableMapOf>() + + /** + * Adds a commit for a given epoch. + * + * @param epoch the MLS epoch number this commit targets + * @param commit the GroupEvent containing the commit + */ + fun addCommit( + epoch: Long, + commit: GroupEvent, + ) { + pendingByEpoch.getOrPut(epoch) { mutableListOf() }.add(commit) + } + + /** + * Returns pending commits for a specific epoch. + */ + fun pendingForEpoch(epoch: Long): List = pendingByEpoch[epoch] ?: emptyList() + + /** + * Resolves the winning commit for a specific epoch. + * + * @param epoch the MLS epoch to resolve + * @return the winning commit, or null if no commits exist for this epoch + */ + fun resolve(epoch: Long): GroupEvent? = selectWinner(pendingByEpoch[epoch] ?: emptyList()) + + /** + * Clears pending commits for an epoch after it has been resolved. + */ + fun clearEpoch(epoch: Long) { + pendingByEpoch.remove(epoch) + } + + /** + * Returns all epochs that have pending commits. + */ + fun pendingEpochs(): Set = pendingByEpoch.keys.toSet() + + /** + * Clears all pending state. + */ + fun clear() { + pendingByEpoch.clear() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEventEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEventEncryption.kt new file mode 100644 index 0000000000..70794850a1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEventEncryption.kt @@ -0,0 +1,96 @@ +/* + * 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.mip03GroupMessages + +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Handles the outer ChaCha20-Poly1305 encryption layer for Marmot GroupEvents (MIP-03). + * + * The encryption flow: + * Encrypt: content = base64(randomNonce(12) || ChaCha20-Poly1305.encrypt(key, nonce, mlsMessageBytes, aad="")) + * Decrypt: decode base64, split nonce (first 12 bytes) from ciphertext+tag, decrypt with empty AAD + * + * The key is derived from MLS-Exporter("marmot", "group-event", 32) by the MLS engine. + * Since the MLS engine is not yet integrated, this helper accepts the 32-byte key as a parameter. + */ +object GroupEventEncryption { + private val EMPTY_AAD = ByteArray(0) + + /** + * Encrypts an MLS message for a GroupEvent. + * + * @param mlsMessageBytes the raw MLS message bytes to encrypt + * @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32) + * @return base64-encoded string containing nonce(12) || ciphertext || tag(16) + */ + @OptIn(ExperimentalEncodingApi::class) + fun encrypt( + mlsMessageBytes: ByteArray, + groupKey: ByteArray, + ): String { + require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) { + "Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes" + } + + val nonce = RandomInstance.bytes(GroupEvent.NONCE_LENGTH) + val ciphertextWithTag = ChaCha20Poly1305.encrypt(mlsMessageBytes, EMPTY_AAD, nonce, groupKey) + + // Prepend nonce to ciphertext+tag + val payload = ByteArray(nonce.size + ciphertextWithTag.size) + nonce.copyInto(payload) + ciphertextWithTag.copyInto(payload, nonce.size) + + return Base64.encode(payload) + } + + /** + * Decrypts a GroupEvent's encrypted content. + * + * @param encryptedContentBase64 base64-encoded content from the GroupEvent + * @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32) + * @return decrypted MLS message bytes + * @throws IllegalStateException if authentication fails + * @throws IllegalArgumentException if content is malformed + */ + @OptIn(ExperimentalEncodingApi::class) + fun decrypt( + encryptedContentBase64: String, + groupKey: ByteArray, + ): ByteArray { + require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) { + "Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes" + } + + val payload = Base64.decode(encryptedContentBase64) + require(payload.size >= GroupEvent.MIN_CONTENT_LENGTH) { + "Payload too short: ${payload.size} bytes, minimum ${GroupEvent.MIN_CONTENT_LENGTH}" + } + + val nonce = payload.copyOfRange(0, GroupEvent.NONCE_LENGTH) + val ciphertextWithTag = payload.copyOfRange(GroupEvent.NONCE_LENGTH, payload.size) + + return ChaCha20Poly1305.decrypt(ciphertextWithTag, EMPTY_AAD, nonce, groupKey) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt new file mode 100644 index 0000000000..1229021e6a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt @@ -0,0 +1,198 @@ +/* + * 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.mip05PushNotifications + +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.tags.TokenTag +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.mac.MacInstance +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Handles EncryptedToken creation and decryption for Marmot push notifications (MIP-05). + * + * EncryptedToken format (280 bytes total): + * ephemeral_pubkey(32) || nonce(12) || ciphertext(236 = 220 plaintext + 16 tag) + * + * Token payload (220 bytes, padded): + * platform(1) || token_length(2 BE) || device_token(N) || random_padding(220-3-N) + * + * Key derivation: + * 1. ECDH: shared_point = ephemeral_privkey * server_pubkey + * 2. shared_x = sha256(shared_point) (x-coordinate as shared secret) + * 3. PRK = HKDF-Extract(salt="mip05-v1", IKM=shared_x) + * 4. encryption_key = HKDF-Expand(PRK, info="mip05-token-encryption", 32) + * 5. Encrypt padded payload with ChaCha20-Poly1305(key, nonce, payload, aad="") + * + * Platform values: 0x01 = APNs, 0x02 = FCM + */ +object TokenEncryption { + private const val PADDED_PAYLOAD_SIZE = 220 + private const val NONCE_SIZE = 12 + private const val PUBKEY_SIZE = 32 + private const val HEADER_SIZE = 3 // platform(1) + token_length(2) + private const val MAX_TOKEN_SIZE = PADDED_PAYLOAD_SIZE - HEADER_SIZE + + private val HKDF_SALT = "mip05-v1".encodeToByteArray() + private val HKDF_INFO = "mip05-token-encryption".encodeToByteArray() + private val EMPTY_AAD = ByteArray(0) + + const val PLATFORM_APNS: Byte = 0x01 + const val PLATFORM_FCM: Byte = 0x02 + + /** + * Encrypts a device token for a notification server. + * + * @param platform platform identifier (PLATFORM_APNS or PLATFORM_FCM) + * @param deviceToken raw device token bytes + * @param serverPubKey 32-byte notification server public key + * @return base64-encoded EncryptedToken (280 bytes when decoded) + */ + @OptIn(ExperimentalEncodingApi::class) + fun encrypt( + platform: Byte, + deviceToken: ByteArray, + serverPubKey: ByteArray, + ): String { + require(deviceToken.size <= MAX_TOKEN_SIZE) { + "Device token too large: ${deviceToken.size} bytes, max $MAX_TOKEN_SIZE" + } + require(serverPubKey.size == PUBKEY_SIZE) { "Server pubkey must be $PUBKEY_SIZE bytes" } + + // Build padded payload: platform(1) || token_length(2 BE) || token || random_padding + val payload = ByteArray(PADDED_PAYLOAD_SIZE) + payload[0] = platform + payload[1] = (deviceToken.size ushr 8 and 0xFF).toByte() + payload[2] = (deviceToken.size and 0xFF).toByte() + deviceToken.copyInto(payload, HEADER_SIZE) + // Fill remaining bytes with random padding + val paddingStart = HEADER_SIZE + deviceToken.size + val padding = RandomInstance.bytes(PADDED_PAYLOAD_SIZE - paddingStart) + padding.copyInto(payload, paddingStart) + + // Generate ephemeral keypair for ECDH + val ephemeralPrivKey = RandomInstance.bytes(32) + val ephemeralPubKey = Secp256k1Instance.compressedPubKeyFor(ephemeralPrivKey) + + // ECDH: shared_x = sha256(ephemeral_privkey * server_pubkey) + val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey) + val sharedX = sha256(sharedPoint) + + // HKDF-Extract then Expand to get encryption key + val encryptionKey = hkdfDeriveKey(sharedX) + + // Encrypt with ChaCha20-Poly1305 + val nonce = RandomInstance.bytes(NONCE_SIZE) + val ciphertextWithTag = ChaCha20Poly1305.encrypt(payload, EMPTY_AAD, nonce, encryptionKey) + + // Assemble: ephemeral_pubkey(32) || nonce(12) || ciphertext+tag(236) + val result = ByteArray(TokenTag.ENCRYPTED_TOKEN_SIZE) + ephemeralPubKey.copyInto(result, 0) + nonce.copyInto(result, PUBKEY_SIZE) + ciphertextWithTag.copyInto(result, PUBKEY_SIZE + NONCE_SIZE) + + return Base64.encode(result) + } + + /** + * Decrypts an EncryptedToken using the notification server's private key. + * + * @param encryptedTokenBase64 base64-encoded EncryptedToken + * @param serverPrivKey 32-byte notification server private key + * @return decoded token info + * @throws IllegalStateException if authentication fails + */ + @OptIn(ExperimentalEncodingApi::class) + fun decrypt( + encryptedTokenBase64: String, + serverPrivKey: ByteArray, + ): DecryptedToken { + val data = Base64.decode(encryptedTokenBase64) + require(data.size == TokenTag.ENCRYPTED_TOKEN_SIZE) { + "EncryptedToken must be ${TokenTag.ENCRYPTED_TOKEN_SIZE} bytes, got ${data.size}" + } + + // Parse components + val ephemeralPubKey = data.copyOfRange(0, PUBKEY_SIZE) + val nonce = data.copyOfRange(PUBKEY_SIZE, PUBKEY_SIZE + NONCE_SIZE) + val ciphertextWithTag = data.copyOfRange(PUBKEY_SIZE + NONCE_SIZE, data.size) + + // ECDH: shared_x = sha256(server_privkey * ephemeral_pubkey) + val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(ephemeralPubKey, serverPrivKey) + val sharedX = sha256(sharedPoint) + + // Derive encryption key + val encryptionKey = hkdfDeriveKey(sharedX) + + // Decrypt + val payload = ChaCha20Poly1305.decrypt(ciphertextWithTag, EMPTY_AAD, nonce, encryptionKey) + + // Parse payload: platform(1) || token_length(2 BE) || token || padding + val platform = payload[0] + val tokenLength = ((payload[1].toInt() and 0xFF) shl 8) or (payload[2].toInt() and 0xFF) + require(tokenLength in 0..MAX_TOKEN_SIZE) { "Invalid token length: $tokenLength" } + + val deviceToken = payload.copyOfRange(HEADER_SIZE, HEADER_SIZE + tokenLength) + + return DecryptedToken(platform, deviceToken) + } + + /** + * HKDF-Extract(salt="mip05-v1", IKM=sharedX) then HKDF-Expand(PRK, info="mip05-token-encryption", 32). + */ + private fun hkdfDeriveKey(sharedX: ByteArray): ByteArray { + // HKDF-Extract: PRK = HMAC-SHA256(salt, IKM) + val extractMac = MacInstance("HmacSHA256", HKDF_SALT) + extractMac.update(sharedX) + val prk = extractMac.doFinal() + + // HKDF-Expand: T(1) = HMAC-SHA256(PRK, info || 0x01), take first 32 bytes + val expandMac = MacInstance("HmacSHA256", prk) + expandMac.update(HKDF_INFO) + expandMac.update(0x01.toByte()) + return expandMac.doFinal() + } + + /** + * Result of decrypting an EncryptedToken. + */ + data class DecryptedToken( + /** Platform identifier: [PLATFORM_APNS] or [PLATFORM_FCM] */ + val platform: Byte, + /** Raw device token bytes */ + val deviceToken: ByteArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DecryptedToken) return false + return platform == other.platform && deviceToken.contentEquals(other.deviceToken) + } + + override fun hashCode(): Int { + var result = platform.toInt() + result = 31 * result + deviceToken.contentHashCode() + return result + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Poly1305.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Poly1305.kt new file mode 100644 index 0000000000..241ea83f61 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Poly1305.kt @@ -0,0 +1,160 @@ +/* + * 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 + +/** + * Standard ChaCha20-Poly1305 AEAD (RFC 8439) with 12-byte nonces. + * + * Used by the Marmot protocol (MIP-03) for outer encryption of GroupEvents. + * Unlike [XChaCha20Poly1305] which uses 24-byte nonces via HChaCha20, + * this operates directly with the IETF ChaCha20 variant. + * + * Construction: + * 1. Generate Poly1305 OTK from ChaCha20 block 0 + * 2. Encrypt with ChaCha20 starting at counter 1 + * 3. Compute Poly1305 tag over: pad16(ad) || pad16(ciphertext) || len(ad) || len(ct) + */ +object ChaCha20Poly1305 { + private const val TAG_SIZE = 16 + + /** + * AEAD encrypt. + * + * @param plaintext message to encrypt + * @param ad associated data (authenticated but not encrypted) + * @param nonce 12-byte nonce + * @param key 32-byte key + * @return ciphertext || 16-byte tag + */ + fun encrypt( + plaintext: ByteArray, + ad: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + require(nonce.size == 12) { "Nonce must be 12 bytes" } + require(key.size == 32) { "Key must be 32 bytes" } + + // Step 1: Generate Poly1305 one-time key + val polyKey = ChaCha20Core.chaCha20PolyKey(key, nonce) + + // Step 2: Encrypt plaintext with counter starting at 1 + val ciphertext = ChaCha20Core.chaCha20Xor(plaintext, key, nonce, counter = 1) + + // Step 3: Compute tag + val tag = computeTag(polyKey, ad, ciphertext) + + // Step 4: Return ciphertext || tag + val result = ByteArray(ciphertext.size + TAG_SIZE) + ciphertext.copyInto(result) + tag.copyInto(result, ciphertext.size) + return result + } + + /** + * AEAD decrypt. + * + * @param ciphertextWithTag ciphertext || 16-byte tag + * @param ad associated data + * @param nonce 12-byte nonce + * @param key 32-byte key + * @return plaintext, or throws on authentication failure + */ + fun decrypt( + ciphertextWithTag: ByteArray, + ad: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + require(nonce.size == 12) { "Nonce must be 12 bytes" } + require(key.size == 32) { "Key must be 32 bytes" } + require(ciphertextWithTag.size >= TAG_SIZE) { "Ciphertext too short" } + + val ctLen = ciphertextWithTag.size - TAG_SIZE + val ciphertext = ciphertextWithTag.copyOfRange(0, ctLen) + val receivedTag = ciphertextWithTag.copyOfRange(ctLen, ciphertextWithTag.size) + + // Step 1: Generate Poly1305 one-time key + val polyKey = ChaCha20Core.chaCha20PolyKey(key, nonce) + + // Step 2: Verify tag + val expectedTag = computeTag(polyKey, ad, ciphertext) + check(constantTimeEquals(receivedTag, expectedTag)) { "Authentication failed" } + + // Step 3: Decrypt + return ChaCha20Core.chaCha20Xor(ciphertext, key, nonce, counter = 1) + } + + /** + * Compute Poly1305 tag over the AEAD construction: + * pad16(ad) || pad16(ciphertext) || len(ad) as 8-byte LE || len(ct) as 8-byte LE + */ + private fun computeTag( + polyKey: ByteArray, + ad: ByteArray, + ciphertext: ByteArray, + ): ByteArray { + val adPadLen = if (ad.size % 16 == 0) 0 else 16 - (ad.size % 16) + val ctPadLen = if (ciphertext.size % 16 == 0) 0 else 16 - (ciphertext.size % 16) + + val macData = ByteArray(ad.size + adPadLen + ciphertext.size + ctPadLen + 16) + var offset = 0 + + ad.copyInto(macData, offset) + offset += ad.size + adPadLen + + ciphertext.copyInto(macData, offset) + offset += ciphertext.size + ctPadLen + + longToLittleEndian(ad.size.toLong(), macData, offset) + offset += 8 + longToLittleEndian(ciphertext.size.toLong(), macData, offset) + + return Poly1305.mac(macData, polyKey) + } + + private fun constantTimeEquals( + a: ByteArray, + b: ByteArray, + ): Boolean { + if (a.size != b.size) return false + var result = 0 + for (i in a.indices) { + result = result or (a[i].toInt() xor b[i].toInt()) + } + return result == 0 + } + + private fun longToLittleEndian( + value: Long, + output: ByteArray, + offset: Int, + ) { + output[offset] = (value and 0xFF).toByte() + output[offset + 1] = (value ushr 8 and 0xFF).toByte() + output[offset + 2] = (value ushr 16 and 0xFF).toByte() + output[offset + 3] = (value ushr 24 and 0xFF).toByte() + output[offset + 4] = (value ushr 32 and 0xFF).toByte() + output[offset + 5] = (value ushr 40 and 0xFF).toByte() + output[offset + 6] = (value ushr 48 and 0xFF).toByte() + output[offset + 7] = (value ushr 56 and 0xFF).toByte() + } +} 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 0000000000..5e337cc568 --- /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 0000000000..20ef6bc015 --- /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 0000000000..e7d7e0e59f --- /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 0000000000..86af1d4aee --- /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 0000000000..f82804b666 --- /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) + } +}