From ae0a0de9fd47e5bb35b2f1a6dcec997566650e6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 17:11:23 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=205=20=E2=80=94=20Marmot=20relay?= =?UTF-8?q?=20subscriptions=20&=20message=20processing=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four protocol-layer components for Marmot group messaging: 1. MarmotSubscriptionManager: Coordinates relay subscriptions for GroupEvent (kind:445), GiftWrap (kind:1059), and KeyPackage (kind:30443) events. Tracks per-group since timestamps for pagination and syncs with MlsGroupManager state. 2. MarmotInboundProcessor: Processes incoming GroupEvents through outer ChaCha20-Poly1305 decryption → MLS decrypt → inner event extraction. Handles commit detection, conflict resolution via CommitOrdering, and Welcome processing with KeyPackage rotation. 3. MarmotOutboundProcessor: Builds outbound GroupEvents by MLS encrypting inner Nostr events, applying ChaCha20-Poly1305 outer layer, and signing with ephemeral keys for sender privacy. 4. MarmotWelcomeSender: Wraps MLS Welcome messages through the NIP-59 gift wrap pipeline for delivery to new group members. All code in quartz/commonMain (protocol layer). Includes 25 tests covering roundtrip encryption, subscription management, error handling, ephemeral key usage, and Welcome wrapping. https://claude.ai/code/session_01XC5umkmsFB7XQ7xdrouArt --- .../quartz/marmot/MarmotInboundProcessor.kt | 374 ++++++++++++++++++ .../quartz/marmot/MarmotOutboundProcessor.kt | 146 +++++++ .../marmot/MarmotSubscriptionManager.kt | 202 ++++++++++ .../quartz/marmot/MarmotWelcomeSender.kt | 129 ++++++ .../marmot/MarmotSubscriptionManagerTest.kt | 194 +++++++++ .../quartz/marmot/MarmotPipelineTest.kt | 372 +++++++++++++++++ 6 files changed, 1417 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManagerTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt new file mode 100644 index 000000000..7c4d68f4c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -0,0 +1,374 @@ +/* + * 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.KeyPackageRotationManager +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.CommitOrdering +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.marmot.mls.framing.ContentType +import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage +import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage +import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Result of processing an inbound GroupEvent (kind:445). + */ +sealed class GroupEventResult { + /** + * An application message was decrypted successfully. + * The [innerEventJson] contains the raw JSON of the inner Nostr event + * (e.g., kind:9 chat, kind:7 reaction). + */ + data class ApplicationMessage( + val groupId: HexKey, + val innerEventJson: String, + val senderLeafIndex: Int, + val epoch: Long, + ) : GroupEventResult() + + /** + * A Commit was processed, advancing the group epoch. + */ + data class CommitProcessed( + val groupId: HexKey, + val newEpoch: Long, + ) : GroupEventResult() + + /** + * A Commit was received but is pending conflict resolution. + * Multiple commits arrived for the same epoch. + */ + data class CommitPending( + val groupId: HexKey, + val epoch: Long, + ) : GroupEventResult() + + /** + * The event could not be processed. + */ + data class Error( + val groupId: HexKey?, + val message: String, + val cause: Exception? = null, + ) : GroupEventResult() +} + +/** + * Result of processing a Welcome message (kind:444 inside kind:1059). + */ +sealed class WelcomeResult { + /** + * Successfully joined a group via Welcome. + */ + data class Joined( + val nostrGroupId: HexKey, + val needsKeyPackageRotation: Boolean, + ) : WelcomeResult() + + /** + * The Welcome could not be processed. + */ + data class Error( + val message: String, + val cause: Exception? = null, + ) : WelcomeResult() +} + +/** + * Processes inbound Marmot events from relays. + * + * Handles: + * - **GroupEvent (kind:445):** Outer ChaCha20 decrypt → MLS decrypt → + * extract inner Nostr event or process Commit + * - **WelcomeEvent (kind:444):** After NIP-59 unwrap reveals a kind:444, + * extract welcome bytes and join the group via MlsGroupManager + * + * This class coordinates between [GroupEventEncryption] (outer layer), + * [MlsGroupManager] (MLS engine), and [CommitOrdering] (conflict resolution). + */ +class MarmotInboundProcessor( + private val groupManager: MlsGroupManager, + private val keyPackageRotationManager: KeyPackageRotationManager, +) { + private val commitTracker = CommitOrdering.EpochCommitTracker() + + /** + * Process an inbound GroupEvent (kind:445). + * + * Flow: + * 1. Extract group ID from `h` tag + * 2. Get the exporter key from MlsGroupManager + * 3. Decrypt outer ChaCha20-Poly1305 layer → raw MLS bytes + * 4. Parse the MLS message to determine type: + * - PrivateMessage with APPLICATION content → MLS decrypt → return inner event + * - PrivateMessage/PublicMessage with COMMIT content → process commit + * - PrivateMessage/PublicMessage with PROPOSAL content → queue proposal + * + * @param groupEvent the incoming kind:445 event + * @return the processing result + */ + suspend fun processGroupEvent(groupEvent: GroupEvent): GroupEventResult { + val groupId = + groupEvent.groupId() + ?: return GroupEventResult.Error(null, "GroupEvent missing h tag (group ID)") + + if (!groupManager.isMember(groupId)) { + return GroupEventResult.Error(groupId, "Not a member of group $groupId") + } + + return try { + // Step 1: Outer ChaCha20-Poly1305 decryption + val exporterKey = groupManager.exporterSecret(groupId) + val mlsBytes = GroupEventEncryption.decrypt(groupEvent.encryptedContent(), exporterKey) + + // Step 2: Parse the MLS message + val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes)) + + when (mlsMessage.wireFormat) { + WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent) + WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent) + else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}") + } + } catch (e: Exception) { + GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e) + } + } + + /** + * Process a WelcomeEvent after NIP-59 gift wrap unwrapping. + * + * Called by the platform layer after unwrapping a GiftWrap → SealedRumor → WelcomeEvent. + * + * Flow: + * 1. Extract welcome bytes and KeyPackage event ID + * 2. Find the matching KeyPackageBundle + * 3. Call MlsGroupManager.processWelcome() + * 4. Mark KeyPackage as consumed for rotation + * + * @param welcomeEvent the unwrapped kind:444 event + * @param nostrGroupId the Nostr group ID (from relay context or Welcome tags) + * @return the processing result + */ + @OptIn(ExperimentalEncodingApi::class) + suspend fun processWelcome( + welcomeEvent: WelcomeEvent, + nostrGroupId: HexKey, + ): WelcomeResult = + try { + val welcomeBytes = Base64.decode(welcomeEvent.welcomeBase64()) + val keyPackageEventId = welcomeEvent.keyPackageEventId() + + // Find the KeyPackageBundle that was consumed + val bundle = + keyPackageRotationManager.findBundleByRef( + hexToBytes(keyPackageEventId), + ) ?: return WelcomeResult.Error( + "No matching KeyPackageBundle found for event $keyPackageEventId", + ) + + // Join the group + groupManager.processWelcome(nostrGroupId, welcomeBytes, bundle) + + // Mark the KeyPackage as consumed — triggers rotation + keyPackageRotationManager.markConsumedByRef(hexToBytes(keyPackageEventId)) + + WelcomeResult.Joined( + nostrGroupId = nostrGroupId, + needsKeyPackageRotation = keyPackageRotationManager.needsRotation(), + ) + } catch (e: Exception) { + WelcomeResult.Error("Failed to process Welcome: ${e.message}", e) + } + + /** + * Resolve any pending commit conflicts for a given epoch. + * + * Call this after a brief delay when multiple commits may arrive for + * the same epoch. The winning commit is applied; losers are discarded. + * + * @param groupId the Nostr group ID + * @param epoch the epoch to resolve + * @return the result of processing the winning commit, or null if no commits pending + */ + suspend fun resolveCommitConflict( + groupId: HexKey, + epoch: Long, + ): GroupEventResult? { + val winner = + commitTracker.resolve(epoch) + ?: return null + + val result = applyCommit(groupId, winner) + commitTracker.clearEpoch(epoch) + return result + } + + /** + * Get all epochs that have pending unresolved commits. + */ + fun pendingCommitEpochs(): Set = commitTracker.pendingEpochs() + + /** + * Clear all pending commit state. + */ + fun clearPendingCommits() { + commitTracker.clear() + } + + private suspend fun processPrivateMessage( + groupId: HexKey, + mlsMessage: MlsMessage, + groupEvent: GroupEvent, + ): GroupEventResult { + // Peek at content type from the PrivateMessage header + val privMsg = PrivateMessage.decodeTls(TlsReader(mlsMessage.payload)) + + return when (privMsg.contentType) { + ContentType.APPLICATION -> { + // MLS decrypt to get the inner plaintext + val decrypted = groupManager.decrypt(groupId, mlsMessage.toTlsBytes()) + GroupEventResult.ApplicationMessage( + groupId = groupId, + innerEventJson = decrypted.content.decodeToString(), + senderLeafIndex = decrypted.senderLeafIndex, + epoch = decrypted.epoch, + ) + } + + ContentType.COMMIT -> { + handleCommitEvent(groupId, groupEvent) + } + + ContentType.PROPOSAL -> { + GroupEventResult.Error(groupId, "Standalone proposals not yet supported") + } + } + } + + private suspend fun processPublicMessage( + groupId: HexKey, + mlsMessage: MlsMessage, + groupEvent: GroupEvent, + ): GroupEventResult { + val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload)) + + return when (pubMsg.contentType) { + ContentType.COMMIT -> { + handleCommitEvent(groupId, groupEvent) + } + + ContentType.PROPOSAL -> { + GroupEventResult.Error(groupId, "Standalone proposals not yet supported") + } + + ContentType.APPLICATION -> { + GroupEventResult.Error(groupId, "Application messages should use PrivateMessage") + } + } + } + + private suspend fun handleCommitEvent( + groupId: HexKey, + groupEvent: GroupEvent, + ): GroupEventResult { + val group = + groupManager.getGroup(groupId) + ?: return GroupEventResult.Error(groupId, "Group not found") + val currentEpoch = group.epoch + commitTracker.addCommit(currentEpoch, groupEvent) + + // If this is the only commit for this epoch, apply immediately + val pending = commitTracker.pendingForEpoch(currentEpoch) + return if (pending.size == 1) { + val result = applyCommit(groupId, groupEvent) + commitTracker.clearEpoch(currentEpoch) + result + } else { + GroupEventResult.CommitPending(groupId, currentEpoch) + } + } + + private suspend fun applyCommit( + groupId: HexKey, + commitEvent: GroupEvent, + ): GroupEventResult = + try { + val exporterKey = groupManager.exporterSecret(groupId) + val mlsBytes = GroupEventEncryption.decrypt(commitEvent.encryptedContent(), exporterKey) + val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes)) + + when (mlsMessage.wireFormat) { + WireFormat.PRIVATE_MESSAGE -> { + // For private commits, MLS decrypt handles epoch advancement + val decrypted = groupManager.decrypt(groupId, mlsMessage.toTlsBytes()) + if (decrypted.contentType == ContentType.COMMIT) { + val group = groupManager.getGroup(groupId) + GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0) + } else { + GroupEventResult.Error( + groupId, + "Expected COMMIT but got ${decrypted.contentType}", + ) + } + } + + WireFormat.PUBLIC_MESSAGE -> { + val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload)) + groupManager.processCommit( + nostrGroupId = groupId, + commitBytes = pubMsg.content, + senderLeafIndex = pubMsg.sender.leafIndex, + confirmationTag = pubMsg.confirmationTag, + ) + val group = groupManager.getGroup(groupId) + GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0) + } + + else -> { + GroupEventResult.Error(groupId, "Unexpected wire format for commit") + } + } + } catch (e: Exception) { + GroupEventResult.Error(groupId, "Failed to apply commit: ${e.message}", e) + } + + private fun hexToBytes(hex: HexKey?): ByteArray { + if (hex == null) return ByteArray(0) + return hex.hexToByteArray() + } + + companion object { + /** + * Check if an unwrapped event is a Marmot WelcomeEvent. + */ + fun isWelcomeEvent(event: Event): Boolean = event.kind == WelcomeEvent.KIND + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt new file mode 100644 index 000000000..9aacda807 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt @@ -0,0 +1,146 @@ +/* + * 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.marmot.mls.group.MlsGroupManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal + +/** + * Result of building an outbound GroupEvent. + */ +data class OutboundGroupEvent( + val signedEvent: GroupEvent, + val nostrGroupId: HexKey, +) + +/** + * Handles outbound Marmot message encryption and event construction. + * + * Creates GroupEvent (kind:445) from inner Nostr events by: + * 1. MLS-encrypting the inner event via MlsGroupManager + * 2. Wrapping with ChaCha20-Poly1305 outer layer via GroupEventEncryption + * 3. Building a GroupEvent with an ephemeral signing key + * + * **Ephemeral keys:** Each outbound kind:445 MUST use a fresh random + * keypair for signing. This is critical for sender privacy — the pubkey + * on the GroupEvent does NOT reveal the actual sender's Nostr identity. + */ +class MarmotOutboundProcessor( + private val groupManager: MlsGroupManager, +) { + /** + * Encrypt an inner Nostr event and build a GroupEvent for publishing. + * + * Flow: + * 1. Serialize the inner event to JSON bytes + * 2. MLS encrypt via MlsGroupManager.encrypt() → MLS ciphertext + * 3. Outer ChaCha20-Poly1305 encrypt via GroupEventEncryption → base64 content + * 4. Build GroupEvent template with the group's `h` tag + * 5. Sign with a fresh ephemeral keypair (NOT the user's Nostr key) + * + * @param nostrGroupId the Nostr group ID to send to + * @param innerEvent the inner Nostr event (e.g., kind:9 chat, kind:7 reaction) + * @return the signed GroupEvent ready for relay publishing + * @throws IllegalStateException if not a member of the group + */ + suspend fun buildGroupEvent( + nostrGroupId: HexKey, + innerEvent: Event, + ): OutboundGroupEvent = buildGroupEventFromBytes(nostrGroupId, innerEvent.toJson().encodeToByteArray()) + + /** + * Encrypt raw bytes and build a GroupEvent for publishing. + * + * This lower-level variant accepts raw bytes instead of an Event, + * useful for sending non-event application data. + * + * @param nostrGroupId the Nostr group ID + * @param plaintext the plaintext bytes to encrypt + * @return the signed GroupEvent ready for relay publishing + */ + suspend fun buildGroupEventFromBytes( + nostrGroupId: HexKey, + plaintext: ByteArray, + ): OutboundGroupEvent { + // Step 1: MLS encrypt + val mlsCiphertext = groupManager.encrypt(nostrGroupId, plaintext) + + // Step 2: Outer ChaCha20-Poly1305 encryption + val exporterKey = groupManager.exporterSecret(nostrGroupId) + val encryptedContent = GroupEventEncryption.encrypt(mlsCiphertext, exporterKey) + + // Step 3: Build the GroupEvent template + val template = + GroupEvent.build( + encryptedContentBase64 = encryptedContent, + nostrGroupId = nostrGroupId, + ) + + // Step 4: Sign with a fresh ephemeral keypair + val ephemeralSigner = NostrSignerInternal(KeyPair()) + val signedEvent: GroupEvent = ephemeralSigner.sign(template) + + return OutboundGroupEvent( + signedEvent = signedEvent, + nostrGroupId = nostrGroupId, + ) + } + + /** + * Build a GroupEvent carrying a Commit for publishing. + * + * Used after MlsGroupManager.commit() or addMember()/removeMember(). + * The commit bytes are already MLS-formatted. + * + * @param nostrGroupId the Nostr group ID + * @param commitBytes the raw MLS commit bytes from CommitResult + * @return the signed GroupEvent ready for relay publishing + */ + suspend fun buildCommitEvent( + nostrGroupId: HexKey, + commitBytes: ByteArray, + ): OutboundGroupEvent { + // Outer ChaCha20-Poly1305 encryption of the MLS commit + val exporterKey = groupManager.exporterSecret(nostrGroupId) + val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey) + + // Build the GroupEvent template + val template = + GroupEvent.build( + encryptedContentBase64 = encryptedContent, + nostrGroupId = nostrGroupId, + ) + + // Sign with a fresh ephemeral keypair + val ephemeralSigner = NostrSignerInternal(KeyPair()) + val signedEvent: GroupEvent = ephemeralSigner.sign(template) + + return OutboundGroupEvent( + signedEvent = signedEvent, + nostrGroupId = nostrGroupId, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt new file mode 100644 index 000000000..9174159ac --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt @@ -0,0 +1,202 @@ +/* + * 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.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +/** + * Subscription state for a single Marmot group. + * + * Tracks the `since` timestamp for pagination so that reconnections + * only fetch events newer than the last seen event. + */ +data class GroupSubscriptionState( + val nostrGroupId: HexKey, + var since: Long? = null, + var active: Boolean = true, +) + +/** + * Coordinates relay subscriptions for Marmot protocol events. + * + * Manages three categories of subscriptions: + * 1. **GroupEvent (kind:445)** — per-group, filtered by `h` tag + * 2. **GiftWrap (kind:1059)** — per-user, for receiving Welcome messages + * 3. **KeyPackage (kind:30443)** — on-demand, for fetching member KeyPackages + * + * The platform layer (amethyst/desktopApp) wires these filters into the + * relay client via [buildFilters] or by polling [activeGroupFilters]. + * + * This class is protocol-only and does NOT depend on Android/UI. It + * produces [Filter] instances that the platform relay client consumes. + */ +class MarmotSubscriptionManager( + private val userPubKey: HexKey, +) { + private val groupSubscriptions = mutableMapOf() + private var giftWrapSince: Long? = null + + /** + * Subscribe to GroupEvents for a group. + * Call this when joining a group or restoring from storage. + * + * @param nostrGroupId hex-encoded Nostr group ID + * @param since optional timestamp to resume from (e.g., last seen event) + */ + fun subscribeGroup( + nostrGroupId: HexKey, + since: Long? = null, + ) { + groupSubscriptions[nostrGroupId] = + GroupSubscriptionState( + nostrGroupId = nostrGroupId, + since = since, + active = true, + ) + } + + /** + * Unsubscribe from a group's events. + * Call this when leaving a group. + */ + fun unsubscribeGroup(nostrGroupId: HexKey) { + groupSubscriptions.remove(nostrGroupId) + } + + /** + * Update the `since` timestamp for a group after processing events. + * This ensures reconnections only fetch newer events. + */ + fun updateGroupSince( + nostrGroupId: HexKey, + since: Long, + ) { + groupSubscriptions[nostrGroupId]?.since = since + } + + /** + * Update the `since` timestamp for gift wrap subscriptions. + */ + fun updateGiftWrapSince(since: Long) { + giftWrapSince = since + } + + /** + * Returns all active group IDs being tracked. + */ + fun activeGroupIds(): Set = groupSubscriptions.filter { it.value.active }.keys + + /** + * Check if a group is currently subscribed. + */ + fun isSubscribed(nostrGroupId: HexKey): Boolean = groupSubscriptions[nostrGroupId]?.active == true + + /** + * Build filters for all active group subscriptions. + * + * Returns one [Filter] per active group (kind:445 filtered by `h` tag), + * using the tracked `since` timestamp for pagination. + */ + fun activeGroupFilters(): List = + groupSubscriptions.values + .filter { it.active } + .map { state -> + if (state.since != null) { + MarmotFilters.groupEventsByGroupIdSince(state.nostrGroupId, state.since!!) + } else { + MarmotFilters.groupEventsByGroupId(state.nostrGroupId) + } + } + + /** + * Build the gift wrap filter for receiving Welcome messages. + * + * Returns a single filter for kind:1059 addressed to the user's pubkey, + * using the tracked `since` timestamp for pagination. + */ + fun giftWrapFilter(): Filter = + if (giftWrapSince != null) { + MarmotFilters.giftWrapsForUserSince(userPubKey, giftWrapSince!!) + } else { + MarmotFilters.giftWrapsForUser(userPubKey) + } + + /** + * Build a KeyPackage filter for a specific user. + * Used on-demand when inviting a user to a group. + */ + fun keyPackageFilter(pubkey: HexKey): Filter = MarmotFilters.keyPackagesByAuthor(pubkey) + + /** + * Build KeyPackage filters for multiple users. + * Used when inviting multiple users at once. + */ + fun keyPackageFilterForMultiple(pubkeys: List): Filter = MarmotFilters.keyPackagesByAuthors(pubkeys) + + /** + * Build all filters needed for the current subscription state. + * + * Returns the combined list of: + * - One filter per active group (kind:445) + * - One gift wrap filter (kind:1059) + * + * The platform layer should send these filters to the relay client + * whenever subscriptions change or on reconnection. + */ + fun buildFilters(): List { + val filters = mutableListOf() + filters.addAll(activeGroupFilters()) + filters.add(giftWrapFilter()) + return filters + } + + /** + * Synchronize subscriptions with the group manager's active groups. + * + * Adds subscriptions for new groups and removes subscriptions + * for groups we're no longer members of. + * + * @param activeGroupIds the set of group IDs from [MlsGroupManager.activeGroupIds] + */ + fun syncWithGroupManager(activeGroupIds: Set) { + // Add new groups + for (groupId in activeGroupIds) { + if (!groupSubscriptions.containsKey(groupId)) { + subscribeGroup(groupId) + } + } + + // Remove stale groups + val staleGroups = groupSubscriptions.keys - activeGroupIds + for (groupId in staleGroups) { + unsubscribeGroup(groupId) + } + } + + /** + * Clear all subscription state. + */ + fun clear() { + groupSubscriptions.clear() + giftWrapSince = null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt new file mode 100644 index 000000000..6d8840fa3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt @@ -0,0 +1,129 @@ +/* + * 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.mip02Welcome.WelcomeGiftWrap +import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult +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.wraps.GiftWrapEvent +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Result of wrapping a Welcome for delivery. + */ +data class WelcomeDelivery( + val giftWrapEvent: GiftWrapEvent, + val recipientPubKey: HexKey, +) + +/** + * Handles wrapping and sending MLS Welcome messages to new group members. + * + * After adding a member to an MLS group (via MlsGroupManager.addMember()), + * the [CommitResult] contains Welcome bytes that must be delivered to the + * new member through NIP-59 gift wrapping. + * + * **CRITICAL timing:** The Commit event (kind:445) MUST be published to + * relays BEFORE calling [wrapWelcome]. This prevents MLS state forks + * where the new member joins at a different epoch than the group. + * + * Delivery pipeline: + * Welcome bytes → base64 → WelcomeEvent (kind:444, unsigned rumor) + * → SealedRumorEvent (kind:13, encrypted with sender's key) + * → GiftWrapEvent (kind:1059, encrypted with ephemeral key) + */ +class MarmotWelcomeSender( + private val signer: NostrSigner, +) { + /** + * Wrap Welcome bytes from a CommitResult for delivery to a new member. + * + * @param commitResult the result from MlsGroupManager.addMember() + * @param recipientPubKey public key of the new member being invited + * @param keyPackageEventId event ID of the KeyPackage that was consumed + * @param relays relays where the new member should subscribe for GroupEvents + * @return the gift-wrapped event ready for publishing, or null if no Welcome in CommitResult + */ + @OptIn(ExperimentalEncodingApi::class) + suspend fun wrapWelcome( + commitResult: CommitResult, + recipientPubKey: HexKey, + keyPackageEventId: HexKey, + relays: List, + ): WelcomeDelivery? { + val welcomeBytes = commitResult.welcomeBytes ?: return null + + val welcomeBase64 = Base64.encode(welcomeBytes) + + val giftWrap = + WelcomeGiftWrap.wrapForRecipient( + welcomeBase64 = welcomeBase64, + keyPackageEventId = keyPackageEventId, + relays = relays, + recipientPubKey = recipientPubKey, + signer = signer, + ) + + return WelcomeDelivery( + giftWrapEvent = giftWrap, + recipientPubKey = recipientPubKey, + ) + } + + /** + * Wrap Welcome bytes directly (not from a CommitResult). + * + * Useful when the Welcome bytes are available separately from the + * commit flow (e.g., re-sending a Welcome after a failed delivery). + * + * @param welcomeBytes raw MLS Welcome message bytes + * @param recipientPubKey public key of the new member + * @param keyPackageEventId event ID of the consumed KeyPackage + * @param relays relays for the new member to subscribe to + * @return the gift-wrapped event ready for publishing + */ + @OptIn(ExperimentalEncodingApi::class) + suspend fun wrapWelcomeBytes( + welcomeBytes: ByteArray, + recipientPubKey: HexKey, + keyPackageEventId: HexKey, + relays: List, + ): WelcomeDelivery { + val welcomeBase64 = Base64.encode(welcomeBytes) + + val giftWrap = + WelcomeGiftWrap.wrapForRecipient( + welcomeBase64 = welcomeBase64, + keyPackageEventId = keyPackageEventId, + relays = relays, + recipientPubKey = recipientPubKey, + signer = signer, + ) + + return WelcomeDelivery( + giftWrapEvent = giftWrap, + recipientPubKey = recipientPubKey, + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManagerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManagerTest.kt new file mode 100644 index 000000000..de82aaa96 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManagerTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot + +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for MarmotSubscriptionManager. + */ +class MarmotSubscriptionManagerTest { + private val userPubKey = "a".repeat(64) + private val groupId1 = "b".repeat(64) + private val groupId2 = "c".repeat(64) + + @Test + fun testSubscribeGroup() { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + + assertTrue(manager.isSubscribed(groupId1)) + assertEquals(setOf(groupId1), manager.activeGroupIds()) + } + + @Test + fun testSubscribeGroupWithSince() { + val manager = MarmotSubscriptionManager(userPubKey) + val since = 1700000000L + + manager.subscribeGroup(groupId1, since) + + assertTrue(manager.isSubscribed(groupId1)) + + val filters = manager.activeGroupFilters() + assertEquals(1, filters.size) + assertEquals(since, filters[0].since) + } + + @Test + fun testUnsubscribeGroup() { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + manager.unsubscribeGroup(groupId1) + + assertFalse(manager.isSubscribed(groupId1)) + assertTrue(manager.activeGroupIds().isEmpty()) + } + + @Test + fun testMultipleGroups() { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + manager.subscribeGroup(groupId2) + + assertEquals(setOf(groupId1, groupId2), manager.activeGroupIds()) + + val filters = manager.activeGroupFilters() + assertEquals(2, filters.size) + } + + @Test + fun testUpdateGroupSince() { + val manager = MarmotSubscriptionManager(userPubKey) + val newSince = 1700000000L + + manager.subscribeGroup(groupId1) + manager.updateGroupSince(groupId1, newSince) + + val filters = manager.activeGroupFilters() + assertEquals(1, filters.size) + assertEquals(newSince, filters[0].since) + } + + @Test + fun testGiftWrapFilter() { + val manager = MarmotSubscriptionManager(userPubKey) + val filter = manager.giftWrapFilter() + + assertEquals(listOf(GiftWrapEvent.KIND), filter.kinds) + assertNotNull(filter.tags) + assertEquals(listOf(userPubKey), filter.tags!!["p"]) + assertNull(filter.since) + } + + @Test + fun testGiftWrapFilterWithSince() { + val manager = MarmotSubscriptionManager(userPubKey) + val since = 1700000000L + + manager.updateGiftWrapSince(since) + val filter = manager.giftWrapFilter() + + assertEquals(since, filter.since) + } + + @Test + fun testActiveGroupFiltersContainCorrectKind() { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + val filters = manager.activeGroupFilters() + + assertEquals(1, filters.size) + assertEquals(listOf(GroupEvent.KIND), filters[0].kinds) + assertNotNull(filters[0].tags) + assertEquals(listOf(groupId1), filters[0].tags!!["h"]) + } + + @Test + fun testBuildFiltersIncludesBothTypes() { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + val allFilters = manager.buildFilters() + + // Should have 1 group filter + 1 gift wrap filter + assertEquals(2, allFilters.size) + } + + @Test + fun testBuildFiltersWithNoGroupsHasGiftWrapOnly() { + val manager = MarmotSubscriptionManager(userPubKey) + val allFilters = manager.buildFilters() + + // Only the gift wrap filter + assertEquals(1, allFilters.size) + assertEquals(listOf(GiftWrapEvent.KIND), allFilters[0].kinds) + } + + @Test + fun testKeyPackageFilter() { + val manager = MarmotSubscriptionManager(userPubKey) + val targetPubKey = "d".repeat(64) + + val filter = manager.keyPackageFilter(targetPubKey) + assertEquals(listOf(targetPubKey), filter.authors) + } + + @Test + fun testSyncWithGroupManager() { + val manager = MarmotSubscriptionManager(userPubKey) + + // Start with one group + manager.subscribeGroup(groupId1) + + // Sync with group manager that has different groups + manager.syncWithGroupManager(setOf(groupId2)) + + // groupId1 should be removed, groupId2 added + assertFalse(manager.isSubscribed(groupId1)) + assertTrue(manager.isSubscribed(groupId2)) + } + + @Test + fun testClear() { + val manager = MarmotSubscriptionManager(userPubKey) + + manager.subscribeGroup(groupId1) + manager.subscribeGroup(groupId2) + manager.updateGiftWrapSince(1700000000L) + + manager.clear() + + assertTrue(manager.activeGroupIds().isEmpty()) + assertNull(manager.giftWrapFilter().since) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt new file mode 100644 index 000000000..1d141151c --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt @@ -0,0 +1,372 @@ +/* + * 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.KeyPackageRotationManager +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * In-memory implementation of [MlsGroupStateStore] for testing. + */ +class TestGroupStateStore : MlsGroupStateStore { + private val states = mutableMapOf() + private val retainedEpochs = mutableMapOf>() + + override suspend fun save( + nostrGroupId: String, + state: ByteArray, + ) { + states[nostrGroupId] = state + } + + override suspend fun load(nostrGroupId: String): ByteArray? = states[nostrGroupId] + + override suspend fun delete(nostrGroupId: String) { + states.remove(nostrGroupId) + retainedEpochs.remove(nostrGroupId) + } + + override suspend fun listGroups(): List = states.keys.toList() + + override suspend fun saveRetainedEpochs( + nostrGroupId: String, + retainedSecrets: List, + ) { + retainedEpochs[nostrGroupId] = retainedSecrets + } + + override suspend fun loadRetainedEpochs(nostrGroupId: String): List = retainedEpochs[nostrGroupId] ?: emptyList() +} + +/** + * Integration tests for the Marmot message processing pipeline. + * + * Tests the full encrypt → wrap → unwrap → decrypt roundtrip through + * MarmotOutboundProcessor and MarmotInboundProcessor. + */ +class MarmotPipelineTest { + private val groupId = "a".repeat(64) + + private fun createGroupManager(): MlsGroupManager = MlsGroupManager(TestGroupStateStore()) + + @Test + fun testOutboundMessageBuildsValidGroupEvent() { + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, "alice".encodeToByteArray()) + + val outbound = MarmotOutboundProcessor(manager) + val result = outbound.buildGroupEventFromBytes(groupId, "Hello group!".encodeToByteArray()) + + val event = result.signedEvent + assertEquals(GroupEvent.KIND, event.kind) + assertEquals(groupId, event.groupId()) + assertTrue(event.content.isNotEmpty()) + assertTrue(event.sig.isNotEmpty()) + } + } + + @Test + fun testOutboundUsesEphemeralKey() { + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, "alice".encodeToByteArray()) + + val outbound = MarmotOutboundProcessor(manager) + val result1 = outbound.buildGroupEventFromBytes(groupId, "msg1".encodeToByteArray()) + val result2 = outbound.buildGroupEventFromBytes(groupId, "msg2".encodeToByteArray()) + + // Each event should have a different ephemeral pubkey + assertNotEquals(result1.signedEvent.pubKey, result2.signedEvent.pubKey) + } + } + + @Test + fun testOutboundEncryptionRoundtrip() { + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, "alice".encodeToByteArray()) + + val plaintext = "Hello from Marmot!" + val outbound = MarmotOutboundProcessor(manager) + val result = outbound.buildGroupEventFromBytes(groupId, plaintext.encodeToByteArray()) + + // Manually decrypt to verify the roundtrip + val exporterKey = manager.exporterSecret(groupId) + val mlsBytes = GroupEventEncryption.decrypt(result.signedEvent.content, exporterKey) + val decrypted = manager.decrypt(groupId, mlsBytes) + + assertEquals(plaintext, decrypted.content.decodeToString()) + } + } + + @Test + fun testInboundProcessesApplicationMessage() { + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, "alice".encodeToByteArray()) + + val keyPackageRotationManager = KeyPackageRotationManager() + val inbound = MarmotInboundProcessor(manager, keyPackageRotationManager) + val outbound = MarmotOutboundProcessor(manager) + + // Build an outbound message + val plaintext = "Hello inbound!" + val outboundResult = + outbound.buildGroupEventFromBytes( + groupId, + plaintext.encodeToByteArray(), + ) + + // Process it as inbound + val result = inbound.processGroupEvent(outboundResult.signedEvent) + + assertIs(result) + assertEquals(groupId, result.groupId) + assertEquals(plaintext, result.innerEventJson) + } + } + + @Test + fun testInboundRejectsNonMemberGroup() { + runBlocking { + val manager = createGroupManager() + val keyPackageRotationManager = KeyPackageRotationManager() + val inbound = MarmotInboundProcessor(manager, keyPackageRotationManager) + + // Create a fake GroupEvent for a group we're not a member of + val unknownGroupId = "f".repeat(64) + val template = + GroupEvent.build( + encryptedContentBase64 = "dGVzdA==", + nostrGroupId = unknownGroupId, + ) + val signer = NostrSignerInternal(KeyPair()) + val fakeEvent: GroupEvent = signer.sign(template) + + val result = inbound.processGroupEvent(fakeEvent) + + assertIs(result) + assertEquals(unknownGroupId, result.groupId) + assertTrue(result.message.contains("Not a member")) + } + } + + @Test + fun testInboundRejectsMissingGroupId() { + runBlocking { + val manager = createGroupManager() + val keyPackageRotationManager = KeyPackageRotationManager() + val inbound = MarmotInboundProcessor(manager, keyPackageRotationManager) + + // Create an event with no h tag + val signer = NostrSignerInternal(KeyPair()) + val event: GroupEvent = + signer.sign( + createdAt = 1700000000L, + kind = GroupEvent.KIND, + tags = arrayOf(), + content = "dGVzdA==", + ) + + val result = inbound.processGroupEvent(event) + + assertIs(result) + assertTrue(result.message.contains("missing h tag")) + } + } + + @Test + fun testCommitEventBuildAndStructure() { + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, "alice".encodeToByteArray()) + + val outbound = MarmotOutboundProcessor(manager) + + // Create a commit + val commitResult = manager.commit(groupId) + val outboundResult = outbound.buildCommitEvent(groupId, commitResult.commitBytes) + + val event = outboundResult.signedEvent + assertEquals(GroupEvent.KIND, event.kind) + assertEquals(groupId, event.groupId()) + assertTrue(event.content.isNotEmpty()) + } + } + + @Test + fun testSubscriptionManagerSyncWithGroupManager() { + runBlocking { + val manager = createGroupManager() + val groupId1 = "1".repeat(64) + val groupId2 = "2".repeat(64) + + manager.createGroup(groupId1, "alice".encodeToByteArray()) + manager.createGroup(groupId2, "alice".encodeToByteArray()) + + val alicePubKey = "a".repeat(64) + val subscriptionManager = MarmotSubscriptionManager(alicePubKey) + subscriptionManager.syncWithGroupManager(manager.activeGroupIds()) + + assertTrue(subscriptionManager.isSubscribed(groupId1)) + assertTrue(subscriptionManager.isSubscribed(groupId2)) + assertEquals(2, subscriptionManager.activeGroupIds().size) + } + } + + @Test + fun testWelcomeEventDetection() { + // WelcomeEvent kind detection helper + val mockEvent = + object : com.vitorpamplona.quartz.nip01Core.core.Event( + "a".repeat(64), + "b".repeat(64), + 1700000000L, + 444, + arrayOf(), + "", + "c".repeat(128), + ) {} + assertTrue(MarmotInboundProcessor.isWelcomeEvent(mockEvent)) + + val nonWelcome = + object : com.vitorpamplona.quartz.nip01Core.core.Event( + "a".repeat(64), + "b".repeat(64), + 1700000000L, + 1, + arrayOf(), + "", + "c".repeat(128), + ) {} + assertTrue(!MarmotInboundProcessor.isWelcomeEvent(nonWelcome)) + } + + @Test + fun testWelcomeSenderWrapsWelcome() { + runBlocking { + val aliceKeyPair = KeyPair() + val aliceSigner = NostrSignerInternal(aliceKeyPair) + + val manager = createGroupManager() + manager.createGroup(groupId, "alice".encodeToByteArray()) + + // Create a KeyPackage for Bob + val group = manager.getGroup(groupId)!! + val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + + // Add Bob to the group + val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes()) + assertNotNull(commitResult.welcomeBytes) + + // Wrap the Welcome + val welcomeSender = MarmotWelcomeSender(aliceSigner) + val bobPubKey = "d".repeat(64) + val delivery = + welcomeSender.wrapWelcome( + commitResult = commitResult, + recipientPubKey = bobPubKey, + keyPackageEventId = "e".repeat(64), + relays = emptyList(), + ) + + assertNotNull(delivery) + assertEquals(bobPubKey, delivery.recipientPubKey) + // The gift wrap event should be kind 1059 + assertEquals(1059, delivery.giftWrapEvent.kind) + } + } + + @Test + fun testCommitOrderingWithProcessor() { + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, "alice".encodeToByteArray()) + + val keyPackageRotationManager = KeyPackageRotationManager() + val inbound = MarmotInboundProcessor(manager, keyPackageRotationManager) + + // Initially no pending commits + assertTrue(inbound.pendingCommitEpochs().isEmpty()) + + // Clear works without error + inbound.clearPendingCommits() + assertTrue(inbound.pendingCommitEpochs().isEmpty()) + } + } + + @Test + fun testMultipleGroupsOutbound() { + runBlocking { + val manager = createGroupManager() + val groupId1 = "1".repeat(64) + val groupId2 = "2".repeat(64) + + manager.createGroup(groupId1, "alice".encodeToByteArray()) + manager.createGroup(groupId2, "alice".encodeToByteArray()) + + val outbound = MarmotOutboundProcessor(manager) + + val result1 = outbound.buildGroupEventFromBytes(groupId1, "msg1".encodeToByteArray()) + val result2 = outbound.buildGroupEventFromBytes(groupId2, "msg2".encodeToByteArray()) + + assertEquals(groupId1, result1.signedEvent.groupId()) + assertEquals(groupId2, result2.signedEvent.groupId()) + } + } + + @Test + fun testFullRoundtripEncryptDecrypt() { + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, "alice".encodeToByteArray()) + + val keyPackageRotationManager = KeyPackageRotationManager() + val outbound = MarmotOutboundProcessor(manager) + val inbound = MarmotInboundProcessor(manager, keyPackageRotationManager) + + // Send multiple messages and verify roundtrip + val messages = listOf("Hello!", "How are you?", "Goodbye!") + + for (msg in messages) { + val outResult = outbound.buildGroupEventFromBytes(groupId, msg.encodeToByteArray()) + val inResult = inbound.processGroupEvent(outResult.signedEvent) + + assertIs(inResult) + assertEquals(msg, inResult.innerEventJson) + assertEquals(groupId, inResult.groupId) + } + } + } +}