Merge pull request #2146 from vitorpamplona/claude/relay-subscriptions-messaging-VOv5P

Add Marmot inbound/outbound message processors and subscription manager
This commit is contained in:
Vitor Pamplona
2026-04-05 14:47:19 -04:00
committed by GitHub
6 changed files with 1417 additions and 0 deletions
@@ -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<Long> = 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
}
}
@@ -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,
)
}
}
@@ -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<HexKey, GroupSubscriptionState>()
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<HexKey> = 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<Filter> =
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<HexKey>): 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<Filter> {
val filters = mutableListOf<Filter>()
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<HexKey>) {
// 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
}
}
@@ -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<NormalizedRelayUrl>,
): 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<NormalizedRelayUrl>,
): 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,
)
}
}
@@ -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)
}
}
@@ -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<String, ByteArray>()
private val retainedEpochs = mutableMapOf<String, List<ByteArray>>()
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<String> = states.keys.toList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
retainedSecrets: List<ByteArray>,
) {
retainedEpochs[nostrGroupId] = retainedSecrets
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = 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<GroupEventResult.ApplicationMessage>(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<GroupEventResult.Error>(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<GroupEventResult.Error>(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<GroupEventResult.ApplicationMessage>(inResult)
assertEquals(msg, inResult.innerEventJson)
assertEquals(groupId, inResult.groupId)
}
}
}
}