diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index e7d99880c..32ac66ea1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -339,6 +339,7 @@ class AppModules( otsResolverBuilder = { otsResolverBuilder.build() }, cache = cache, client = client, + rootFilesDir = { appContext.filesDir }, ) val sessionManager = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2349a7307..24a60819f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache @@ -129,6 +130,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.dimension import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.mimeType +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -251,6 +253,7 @@ class Account( val cache: LocalCache, val client: INostrClient, val scope: CoroutineScope, + val mlsGroupStateStore: MlsGroupStateStore? = null, ) : IAccount { private var userProfileCache: User? = null @@ -378,6 +381,8 @@ class Account( val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) + val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it) } + val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings) val feedDecryptionCaches = @@ -1704,6 +1709,98 @@ class Account( } } + // --- Marmot Group Messaging --- + + /** + * Send a message to a Marmot MLS group. + * Encrypts the inner event and publishes the GroupEvent to group relays. + */ + suspend fun sendMarmotGroupMessage( + nostrGroupId: HexKey, + innerEvent: Event, + groupRelays: Set, + ) { + val manager = marmotManager ?: return + if (!isWriteable()) return + + val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent) + cache.justConsumeMyOwnEvent(outbound.signedEvent) + client.publish(outbound.signedEvent, groupRelays) + } + + /** + * Add a member to a Marmot MLS group. + * Publishes the commit GroupEvent, then sends the Welcome gift wrap. + */ + suspend fun addMarmotGroupMember( + nostrGroupId: HexKey, + memberPubKey: HexKey, + keyPackageBytes: ByteArray, + keyPackageEventId: HexKey, + groupRelays: List, + ) { + val manager = marmotManager ?: return + if (!isWriteable()) return + + val (commitEvent, welcomeDelivery) = + manager.addMember( + nostrGroupId = nostrGroupId, + memberPubKey = memberPubKey, + keyPackageBytes = keyPackageBytes, + keyPackageEventId = keyPackageEventId, + relays = groupRelays, + ) + + // Publish commit first (critical ordering) + client.publish(commitEvent.signedEvent, groupRelays.toSet()) + + // Then send Welcome gift wrap to the new member + if (welcomeDelivery != null) { + val relayList = computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent) + client.publish(welcomeDelivery.giftWrapEvent, relayList) + } + } + + /** + * Publish or rotate KeyPackage events. + */ + suspend fun publishMarmotKeyPackages() { + val manager = marmotManager ?: return + if (!isWriteable()) return + + val relays = outboxRelays.flow.value.toList() + + if (manager.needsKeyPackageRotation()) { + val rotatedEvents = manager.rotateConsumedKeyPackages(relays) + rotatedEvents.forEach { event -> + cache.justConsumeMyOwnEvent(event) + client.publish(event, outboxRelays.flow.value) + } + } + } + + /** + * Generate and publish initial KeyPackage for this account. + */ + suspend fun publishMarmotKeyPackage() { + val manager = marmotManager ?: return + if (!isWriteable()) return + + val relays = outboxRelays.flow.value.toList() + val event = manager.generateKeyPackageEvent(relays) + cache.justConsumeMyOwnEvent(event) + client.publish(event, outboxRelays.flow.value) + } + + /** + * Create a new Marmot MLS group. + */ + suspend fun createMarmotGroup(nostrGroupId: HexKey) { + val manager = marmotManager ?: return + if (!isWriteable()) return + manager.createGroup(nostrGroupId) + } + suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) { @@ -2162,6 +2259,13 @@ class Account( init { Log.d("AccountRegisterObservers", "Init") + // Restore Marmot MLS group state on startup + if (marmotManager != null) { + scope.launch(Dispatchers.IO) { + marmotManager.restoreAll() + } + } + scope.launch { cache.antiSpam.flowSpam.collect { it.cache.spamMessages.snapshot().values.forEach { spammer -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index abb3c7d12..8541b0e28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -63,6 +63,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -2613,6 +2615,7 @@ object LocalCache : ILocalCache, ICacheProvider { is GeohashListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is GoalEvent -> consumeRegularEvent(event, relay, wasVerified) is GiftWrapEvent -> consumeRegularEvent(event, relay, wasVerified) + is GroupEvent -> consumeRegularEvent(event, relay, wasVerified) is GitIssueEvent -> consumeRegularEvent(event, relay, wasVerified) is GitReplyEvent -> consumeRegularEvent(event, relay, wasVerified) is GitPatchEvent -> consumeRegularEvent(event, relay, wasVerified) @@ -2622,6 +2625,7 @@ object LocalCache : ILocalCache, ICacheProvider { is ChessGameEvent -> consumeRegularEvent(event, relay, wasVerified) is RelayFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is JesterEvent -> consumeRegularEvent(event, relay, wasVerified) + is KeyPackageEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveChessGameChallengeEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveChessGameAcceptEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveChessMoveEvent -> consumeBaseReplaceable(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 30379256b..37686212b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -24,6 +24,7 @@ import android.content.ContentResolver import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -43,6 +44,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import java.io.File class AccountCacheState( val geolocationFlow: () -> StateFlow, @@ -51,6 +53,7 @@ class AccountCacheState( val otsResolverBuilder: () -> OtsResolver, val cache: LocalCache, val client: INostrClient, + val rootFilesDir: () -> File = { File("") }, ) { val accounts = MutableStateFlow>(emptyMap()) @@ -94,6 +97,13 @@ class AccountCacheState( val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME) + val mlsStore = + try { + AndroidMlsGroupStateStore(rootFilesDir()) + } catch (_: Exception) { + null + } + return Account( settings = accountSettings, signer = signerWithClientTag, @@ -110,6 +120,7 @@ class AccountCacheState( Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception: ${throwable.message}", throwable) }, ), + mlsGroupStateStore = mlsStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt new file mode 100644 index 000000000..7ee711fd5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt @@ -0,0 +1,147 @@ +/* + * 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.amethyst.model.marmot + +import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Android implementation of [MlsGroupStateStore] using file-based encrypted storage. + * + * All MLS group state (containing private keys and epoch secrets) is encrypted + * at rest using [KeyStoreEncryption] (AES/GCM backed by Android KeyStore). + * + * Storage layout: + * ``` + * /mls_groups//state — encrypted MlsGroupState + * /mls_groups//retained — encrypted retained epoch secrets + * ``` + */ +class AndroidMlsGroupStateStore( + private val rootDir: File, + private val encryption: KeyStoreEncryption = KeyStoreEncryption(), +) : MlsGroupStateStore { + private fun groupDir(nostrGroupId: String): File = File(rootDir, "mls_groups/$nostrGroupId") + + private fun stateFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "state") + + private fun retainedFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "retained") + + override suspend fun save( + nostrGroupId: String, + state: ByteArray, + ) = withContext(Dispatchers.IO) { + val file = stateFile(nostrGroupId) + file.parentFile?.mkdirs() + file.writeBytes(encryption.encrypt(state)) + } + + override suspend fun load(nostrGroupId: String): ByteArray? = + withContext(Dispatchers.IO) { + val file = stateFile(nostrGroupId) + if (!file.exists()) return@withContext null + encryption.decrypt(file.readBytes()) + } + + override suspend fun delete(nostrGroupId: String) { + withContext(Dispatchers.IO) { + val dir = groupDir(nostrGroupId) + dir.deleteRecursively() + } + } + + override suspend fun listGroups(): List = + withContext(Dispatchers.IO) { + val baseDir = File(rootDir, "mls_groups") + if (!baseDir.exists()) return@withContext emptyList() + baseDir + .listFiles() + ?.filter { it.isDirectory && File(it, "state").exists() } + ?.map { it.name } + ?: emptyList() + } + + override suspend fun saveRetainedEpochs( + nostrGroupId: String, + retainedSecrets: List, + ) = withContext(Dispatchers.IO) { + val file = retainedFile(nostrGroupId) + file.parentFile?.mkdirs() + + // Format: 4-byte count, then for each: 4-byte length + data + val totalSize = 4 + retainedSecrets.sumOf { 4 + it.size } + val buffer = ByteArray(totalSize) + var offset = 0 + + // Write count + val count = retainedSecrets.size + buffer[offset++] = (count shr 24).toByte() + buffer[offset++] = (count shr 16).toByte() + buffer[offset++] = (count shr 8).toByte() + buffer[offset++] = count.toByte() + + // Write each entry + for (secret in retainedSecrets) { + val len = secret.size + buffer[offset++] = (len shr 24).toByte() + buffer[offset++] = (len shr 16).toByte() + buffer[offset++] = (len shr 8).toByte() + buffer[offset++] = len.toByte() + secret.copyInto(buffer, offset) + offset += len + } + + file.writeBytes(encryption.encrypt(buffer)) + } + + override suspend fun loadRetainedEpochs(nostrGroupId: String): List = + withContext(Dispatchers.IO) { + val file = retainedFile(nostrGroupId) + if (!file.exists()) return@withContext emptyList() + + val buffer = encryption.decrypt(file.readBytes()) ?: return@withContext emptyList() + if (buffer.size < 4) return@withContext emptyList() + + var offset = 0 + val count = + ((buffer[offset++].toInt() and 0xFF) shl 24) or + ((buffer[offset++].toInt() and 0xFF) shl 16) or + ((buffer[offset++].toInt() and 0xFF) shl 8) or + (buffer[offset++].toInt() and 0xFF) + + val result = mutableListOf() + for (i in 0 until count) { + if (offset + 4 > buffer.size) break + val len = + ((buffer[offset++].toInt() and 0xFF) shl 24) or + ((buffer[offset++].toInt() and 0xFF) shl 16) or + ((buffer[offset++].toInt() and 0xFF) shl 8) or + (buffer[offset++].toInt() and 0xFF) + if (offset + len > buffer.size) break + result.add(buffer.copyOfRange(offset, offset + len)) + offset += len + } + result + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 1c387a304..bbf37f88c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.AccountFollowsLoaderSubAssembler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager @@ -64,6 +65,7 @@ class AccountFilterAssembler( AccountDraftsEoseManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys), + MarmotGroupEventsEoseManager(client, ::allKeys), ) override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt new file mode 100644 index 000000000..4736334d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * EOSE manager for Marmot GroupEvent (kind:445) subscriptions. + * + * Uses filters from [MarmotSubscriptionManager.buildFilters()] which + * include per-group kind:445 filters. These filters are sent to the + * group's relays on connect/reconnect and when groups are added/removed. + */ +class MarmotGroupEventsEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + val manager = key.account.marmotManager ?: return emptyList() + if (!key.account.isWriteable()) return emptyList() + + // Build Marmot filters (kind:445 per group) + val marmotFilters = manager.subscriptionManager.activeGroupFilters() + if (marmotFilters.isEmpty()) return emptyList() + + // Send to the home relays (where group events are relayed) + val relays = key.account.homeRelays.flow.value + if (relays.isEmpty()) return emptyList() + + return relays.flatMap { relay -> + marmotFilters.map { filter -> + RelayBasedFilter( + relay = relay, + filter = filter, + ) + } + } + } + + val userJobMap = mutableMapOf>() + + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + key.account.homeRelays.flow.collect { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 8b6c15a4f..c9b9544f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -26,6 +26,11 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.marmot.GroupEventResult +import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor +import com.vitorpamplona.quartz.marmot.WelcomeResult +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.IEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -59,6 +64,8 @@ class EventProcessor( private val zapRequest = LnZapRequestEventHandler(account.privateZapsDecryptionCache) private val zapEvent = LnZapEventHandler(account.privateZapsDecryptionCache) + private val groupEventHandler = GroupEventHandler(account, cache) + var callManager: CallManager? = null suspend fun consume(note: Note) { @@ -89,6 +96,8 @@ class EventProcessor( is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) + is GroupEvent -> groupEventHandler.add(event, eventNote, publicNote) + is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) @@ -287,6 +296,13 @@ class GiftWrapEventHandler( val innerGift = event.unwrapOrNull(account.signer) ?: return eventNote.event = event.copyNoContent() + + // Check if the unwrapped event is a Marmot WelcomeEvent (kind:444) + if (MarmotInboundProcessor.isWelcomeEvent(innerGift)) { + processMarmotWelcome(innerGift, eventNote, publicNote) + return + } + if (cache.justConsume(innerGift, null, false)) { cache.copyRelaysFromTo(publicNote, innerGift) val innerGiftNote = cache.getOrCreateNote(innerGift.id) @@ -294,6 +310,34 @@ class GiftWrapEventHandler( } } + private suspend fun processMarmotWelcome( + innerEvent: Event, + eventNote: Note, + publicNote: Note, + ) { + val manager = account.marmotManager ?: return + if (innerEvent !is WelcomeEvent) return + + val nostrGroupId = innerEvent.nostrGroupId() ?: return + + val result = manager.processWelcome(innerEvent, nostrGroupId) + + when (result) { + is WelcomeResult.Joined -> { + Log.d("GiftWrapEventHandler", "Joined Marmot group ${result.nostrGroupId}") + + // Rotate KeyPackages if needed + if (result.needsKeyPackageRotation) { + account.publishMarmotKeyPackages() + } + } + + is WelcomeResult.Error -> { + Log.w("GiftWrapEventHandler") { "Failed to process Marmot Welcome: ${result.message}" } + } + } + } + private suspend fun processExistingGiftWrap( innerGiftId: String, publicNote: Note, @@ -400,3 +444,55 @@ class LnZapEventHandler( } } } + +/** + * Handles inbound GroupEvent (kind:445) for Marmot MLS group messaging. + * + * Decrypts the outer ChaCha20-Poly1305 layer and the inner MLS layer, + * then indexes the resulting inner event in LocalCache. + */ +class GroupEventHandler( + private val account: Account, + private val cache: LocalCache, +) : EventHandler { + override suspend fun add( + event: GroupEvent, + eventNote: Note, + publicNote: Note, + ) { + val manager = account.marmotManager ?: return + + val groupId = event.groupId() ?: return + if (!manager.isMember(groupId)) return + + try { + val result = manager.processGroupEvent(event) + + when (result) { + is GroupEventResult.ApplicationMessage -> { + // Parse the inner event JSON and index it + val innerEvent = Event.fromJson(result.innerEventJson) + if (cache.justConsume(innerEvent, null, false)) { + val innerNote = cache.getOrCreateNote(innerEvent.id) + innerNote.event = innerEvent + } + } + + is GroupEventResult.CommitProcessed -> { + Log.d("GroupEventHandler", "Commit processed for group ${result.groupId}, epoch=${result.newEpoch}") + } + + is GroupEventResult.CommitPending -> { + Log.d("GroupEventHandler", "Commit pending for group ${result.groupId}, epoch=${result.epoch}") + } + + is GroupEventResult.Error -> { + Log.w("GroupEventHandler") { "Error processing GroupEvent: ${result.message}" } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("GroupEventHandler", "Failed to process GroupEvent", e) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt new file mode 100644 index 000000000..9cc905b5d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -0,0 +1,257 @@ +/* + * 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.amethyst.commons.marmot + +import com.vitorpamplona.quartz.marmot.GroupEventResult +import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor +import com.vitorpamplona.quartz.marmot.MarmotOutboundProcessor +import com.vitorpamplona.quartz.marmot.MarmotSubscriptionManager +import com.vitorpamplona.quartz.marmot.MarmotWelcomeSender +import com.vitorpamplona.quartz.marmot.OutboundGroupEvent +import com.vitorpamplona.quartz.marmot.WelcomeDelivery +import com.vitorpamplona.quartz.marmot.WelcomeResult +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.Log +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Central coordinator for Marmot MLS group messaging. + * + * Holds all Marmot components and provides high-level operations for: + * - Group lifecycle (create, join, leave) + * - Message sending and receiving + * - KeyPackage management and rotation + * - Subscription filter coordination + * + * Initialized during Account startup; all methods should be called + * from the Account's coroutine scope. + */ +class MarmotManager( + val signer: NostrSigner, + store: MlsGroupStateStore, +) { + val groupManager = MlsGroupManager(store) + val keyPackageRotationManager = KeyPackageRotationManager() + val subscriptionManager = MarmotSubscriptionManager(signer.pubKey) + val inboundProcessor = MarmotInboundProcessor(groupManager, keyPackageRotationManager) + val outboundProcessor = MarmotOutboundProcessor(groupManager) + val welcomeSender = MarmotWelcomeSender(signer) + + /** + * Restore all Marmot state from persistent storage. + * Call once during Account initialization. + */ + suspend fun restoreAll() { + try { + groupManager.restoreAll() + subscriptionManager.syncWithGroupManager(groupManager.activeGroupIds()) + Log.d("MarmotManager", "Restored ${groupManager.activeGroupIds().size} groups") + } catch (e: Exception) { + Log.e("MarmotManager", "Failed to restore Marmot state", e) + } + } + + // --- Inbound Processing --- + + /** + * Process an inbound GroupEvent (kind:445). + * Returns the inner event JSON if it was an application message. + */ + suspend fun processGroupEvent(groupEvent: GroupEvent): GroupEventResult { + val result = inboundProcessor.processGroupEvent(groupEvent) + + // Update subscription timestamp + when (result) { + is GroupEventResult.ApplicationMessage -> { + subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) + } + + is GroupEventResult.CommitProcessed -> { + subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) + } + + is GroupEventResult.CommitPending, + is GroupEventResult.Error, + -> {} + } + + return result + } + + /** + * Process a WelcomeEvent (kind:444) after NIP-59 unwrapping. + * Returns the result including whether KeyPackage rotation is needed. + */ + suspend fun processWelcome( + welcomeEvent: WelcomeEvent, + nostrGroupId: HexKey, + ): WelcomeResult { + val result = inboundProcessor.processWelcome(welcomeEvent, nostrGroupId) + + if (result is WelcomeResult.Joined) { + // Update subscription state for the new group + subscriptionManager.subscribeGroup(result.nostrGroupId) + Log.d("MarmotManager", "Joined group ${result.nostrGroupId}") + } + + return result + } + + // --- Outbound Operations --- + + /** + * Build a GroupEvent for sending a message to a group. + */ + suspend fun buildGroupMessage( + nostrGroupId: HexKey, + innerEvent: Event, + ): OutboundGroupEvent = outboundProcessor.buildGroupEvent(nostrGroupId, innerEvent) + + /** + * Add a member to a group. + * Returns the commit GroupEvent to publish, and the WelcomeDelivery for the new member. + */ + suspend fun addMember( + nostrGroupId: HexKey, + memberPubKey: HexKey, + keyPackageBytes: ByteArray, + keyPackageEventId: HexKey, + relays: List, + ): Pair { + val commitResult = groupManager.addMember(nostrGroupId, keyPackageBytes) + val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.commitBytes) + + val welcomeDelivery = + welcomeSender.wrapWelcome( + commitResult = commitResult, + recipientPubKey = memberPubKey, + keyPackageEventId = keyPackageEventId, + relays = relays, + nostrGroupId = nostrGroupId, + ) + + return Pair(commitEvent, welcomeDelivery) + } + + /** + * Create a new MLS group. + */ + suspend fun createGroup(nostrGroupId: HexKey): HexKey { + val identity = signer.pubKey.hexToByteArray() + groupManager.createGroup(nostrGroupId, identity) + subscriptionManager.subscribeGroup(nostrGroupId) + return nostrGroupId + } + + /** + * Leave a group. + * Returns proposal bytes to publish (as a GroupEvent). + */ + suspend fun leaveGroup(nostrGroupId: HexKey): OutboundGroupEvent { + val proposalBytes = groupManager.leaveGroup(nostrGroupId) + subscriptionManager.unsubscribeGroup(nostrGroupId) + return outboundProcessor.buildCommitEvent(nostrGroupId, proposalBytes) + } + + // --- KeyPackage Management --- + + /** + * Generate and build a KeyPackage event template for publishing. + * Returns the signed KeyPackageEvent ready for publishing. + */ + @OptIn(ExperimentalEncodingApi::class) + suspend fun generateKeyPackageEvent( + relays: List, + dTagSlot: String = KeyPackageUtils.PRIMARY_SLOT, + ): KeyPackageEvent { + val identity = signer.pubKey.hexToByteArray() + val bundle = keyPackageRotationManager.generateKeyPackage(identity, dTagSlot) + + val keyPackageBytes = bundle.keyPackage.toTlsBytes() + val keyPackageBase64 = Base64.encode(keyPackageBytes) + val keyPackageRef = bundle.keyPackage.reference().toHexKey() + + val template = + KeyPackageEvent.build( + keyPackageBase64 = keyPackageBase64, + dTagSlot = dTagSlot, + keyPackageRef = keyPackageRef, + relays = relays, + ) + + return signer.sign(template) + } + + /** + * Rotate consumed KeyPackage slots. + * Returns list of KeyPackageEvents to publish. + */ + @OptIn(ExperimentalEncodingApi::class) + suspend fun rotateConsumedKeyPackages(relays: List): List { + val pendingSlots = keyPackageRotationManager.pendingRotationSlots() + if (pendingSlots.isEmpty()) return emptyList() + + val identity = signer.pubKey.hexToByteArray() + return pendingSlots.map { slot -> + val bundle = keyPackageRotationManager.rotateSlot(identity, slot) + val keyPackageBytes = bundle.keyPackage.toTlsBytes() + val keyPackageBase64 = Base64.encode(keyPackageBytes) + val keyPackageRef = bundle.keyPackage.reference().toHexKey() + + val template = + KeyPackageEvent.build( + keyPackageBase64 = keyPackageBase64, + dTagSlot = slot, + keyPackageRef = keyPackageRef, + relays = relays, + ) + signer.sign(template) + } + } + + /** + * Check if KeyPackage rotation is needed. + */ + fun needsKeyPackageRotation(): Boolean = keyPackageRotationManager.needsRotation() + + /** + * Check if a specific group membership exists. + */ + fun isMember(nostrGroupId: HexKey): Boolean = groupManager.isMember(nostrGroupId) + + /** + * Get all active group IDs. + */ + fun activeGroupIds(): Set = groupManager.activeGroupIds() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt index 6d8840fa3..3bd3d2d2f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt @@ -71,6 +71,7 @@ class MarmotWelcomeSender( recipientPubKey: HexKey, keyPackageEventId: HexKey, relays: List, + nostrGroupId: HexKey? = null, ): WelcomeDelivery? { val welcomeBytes = commitResult.welcomeBytes ?: return null @@ -83,6 +84,7 @@ class MarmotWelcomeSender( relays = relays, recipientPubKey = recipientPubKey, signer = signer, + nostrGroupId = nostrGroupId, ) return WelcomeDelivery( @@ -109,6 +111,7 @@ class MarmotWelcomeSender( recipientPubKey: HexKey, keyPackageEventId: HexKey, relays: List, + nostrGroupId: HexKey? = null, ): WelcomeDelivery { val welcomeBase64 = Base64.encode(welcomeBytes) @@ -119,6 +122,7 @@ class MarmotWelcomeSender( relays = relays, recipientPubKey = recipientPubKey, signer = signer, + nostrGroupId = nostrGroupId, ) return WelcomeDelivery( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeEvent.kt index db40824a6..552f9f07b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeEvent.kt @@ -67,6 +67,12 @@ class WelcomeEvent( /** Content encoding (must be "base64") */ fun encoding() = tags.welcomeEncoding() + /** Nostr group ID from the "h" tag (for recipient to identify the group) */ + fun nostrGroupId(): HexKey? = + tags.firstNotNullOfOrNull { tag -> + if (tag.size >= 2 && tag[0] == "h") tag[1] else null + } + override fun isContentEncoded() = true companion object { @@ -77,12 +83,14 @@ class WelcomeEvent( welcomeBase64: String, keyPackageEventId: HexKey, relays: List, + nostrGroupId: HexKey? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, welcomeBase64, createdAt) { keyPackageEventId(keyPackageEventId) welcomeRelays(relays) encoding() + nostrGroupId?.let { addUnique(arrayOf("h", it)) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt index 41c56b8e1..da717ab1e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt @@ -57,6 +57,7 @@ object WelcomeGiftWrap { relays: List, recipientPubKey: HexKey, signer: NostrSigner, + nostrGroupId: HexKey? = null, createdAt: Long = TimeUtils.now(), ): GiftWrapEvent { // Step 1: Build the WelcomeEvent template and sign it @@ -65,6 +66,7 @@ object WelcomeGiftWrap { welcomeBase64 = welcomeBase64, keyPackageEventId = keyPackageEventId, relays = relays, + nostrGroupId = nostrGroupId, createdAt = createdAt, ) val welcomeEvent: WelcomeEvent = signer.sign(welcomeTemplate)