feat: Phase 5 — Marmot relay subscriptions & message processing pipeline
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
This commit is contained in:
+374
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+202
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+194
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+372
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user