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:
+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