feat: Phase 6 — Marmot account integration & event routing

Wire Marmot MLS group messaging into the app's Account lifecycle,
event processing pipeline, and relay subscription system.

New files:
- AndroidMlsGroupStateStore: encrypted file-based MLS state storage
  using KeyStoreEncryption (AES/GCM backed by Android KeyStore)
- MarmotManager: central coordinator holding all Marmot components
  (MlsGroupManager, KeyPackageRotationManager, subscription/inbound/
  outbound processors, WelcomeSender)
- MarmotGroupEventsEoseManager: relay subscription manager for
  kind:445 GroupEvent filters via AccountFilterAssembler

Account integration:
- MarmotManager initialized during Account startup with restoreAll()
- Outbound methods: sendMarmotGroupMessage, addMarmotGroupMember,
  publishMarmotKeyPackage(s), createMarmotGroup
- GroupEvent (kind:445) and KeyPackageEvent (kind:30443) added to
  LocalCache.justConsumeInnerInner() dispatch

Event routing:
- GroupEventHandler: processes inbound kind:445 events through
  MarmotInboundProcessor, indexes decrypted inner events in LocalCache
- GiftWrapEventHandler extended: detects kind:444 WelcomeEvent after
  NIP-59 unwrap, routes to MarmotManager.processWelcome(), triggers
  KeyPackage rotation when needed
- WelcomeEvent enhanced with optional "h" tag for group ID routing

https://claude.ai/code/session_01W2LHazEt4E3W4hn8f7gWVW
This commit is contained in:
Claude
2026-04-05 19:54:56 +00:00
parent 4e56f68a8f
commit 8b56b3b3bf
12 changed files with 732 additions and 0 deletions
@@ -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<NormalizedRelayUrl>,
): Pair<OutboundGroupEvent, WelcomeDelivery?> {
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<NormalizedRelayUrl>,
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<NormalizedRelayUrl>): List<KeyPackageEvent> {
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<KeyPackageEvent>(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<HexKey> = groupManager.activeGroupIds()
}