Observe unprocessed marmot inner events

This commit is contained in:
Kgothatso Ngako
2026-07-04 00:34:51 +02:00
parent 9592e37a45
commit 9bce33ba40
7 changed files with 204 additions and 23 deletions

View File

@@ -5,6 +5,7 @@ import androidx.room3.RoomDatabase
import androidx.room3.TypeConverters
import androidx.room3.immediateTransaction
import androidx.room3.useWriterConnection
import at.torch.compose.database.dao.MarmotOutboundDao
import at.torch.compose.database.dao.MarmotGroupEventDao
import at.torch.compose.database.dao.MarmotInnerEventDao
import at.torch.compose.database.dao.MarmotKeyPackageBundleDao
@@ -109,6 +110,8 @@ abstract class TorchDatabase: RoomDatabase() {
abstract fun inReplyToRelationDao(): at.torch.compose.database.dao.InReplyToRelationDao
abstract fun marmotOutboundDao(): MarmotOutboundDao
abstract fun marmotGroupEventDao(): MarmotGroupEventDao
abstract fun marmotInnerEventDao(): MarmotInnerEventDao

View File

@@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface MarmotInnerEventDao {
@Query("SELECT * FROM MarmotInnerEvent WHERE publicKey = :publicKey AND marmotGroupEventId IS NULL")
fun observeUnprocessedMarmotInnerEvents(publicKey: String): Flow<MarmotInnerEvent?>
@Upsert
suspend fun upsert(marmotInnerEvent: MarmotInnerEvent)
}

View File

@@ -0,0 +1,104 @@
package at.torch.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Transaction
import at.torch.compose.database.TorchDatabase
import at.torch.compose.database.model.BroadcastNostrEventRequest
import at.torch.compose.database.model.MarmotGroupEvent
import at.torch.compose.database.model.MarmotInnerEvent.Companion.disappearingExpiration
import at.torch.compose.database.model.NostrEvent
import at.torch.compose.database.model.intermdiate.LocalChatRoom
import at.torch.compose.extensions.exporterSecret
import at.torch.compose.nostr.Relays
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.time.Instant
@Dao
abstract class MarmotOutboundDao(
private val database: TorchDatabase
) {
val logger = Logger.withTag(TAG)
@Transaction
suspend fun encryptAndSendMarmotInnerEvent(
localChatRoom: LocalChatRoom,
mlsGroup: MlsGroup,
innerEvent: Event,
nostrSignerSync: NostrSignerSync
) {
val plainTextBytes = innerEvent.toJson().encodeToByteArray()
// Step 1: MLS encrypt
val mlsCipherText = mlsGroup.encrypt(plainTextBytes)
// Step 2: Outer ChaCha20-Poly1305 encryption
val exportKey = mlsGroup.exporterSecret()
val encryptedContent = GroupEventEncryption.encrypt(
mlsMessageBytes = mlsCipherText,
groupKey = exportKey
)
// Step 3: Build the GroupEvent template (auto-apply NIP-40 expiration
// if the group has disappearing_message_secs configured per MIP-01/03).
val createdAt = TimeUtils.now()
val expirationTime = disappearingExpiration(mlsGroup, createdAt)
val template = GroupEvent.build(
encryptedContentBase64 = encryptedContent,
nostrGroupId = localChatRoom.chatRoom.id,
createdAt = createdAt,
) {
if (expirationTime != null) expiration(expirationTime)
}
// Step 4: Sign with a fresh ephemeral keypair
val ephemeralSigner = NostrSignerInternal(KeyPair())
val groupEvent: GroupEvent = ephemeralSigner.sign(template)
database.nostrEventDao().upsert(
NostrEvent(
id = groupEvent.id,
tags = template.tags,
createdAt = Instant.fromEpochSeconds(groupEvent.createdAt),
content = template.content,
sig = groupEvent.sig,
kind = groupEvent.kind,
pubKey = groupEvent.pubKey
)
)
database.marmotGroupEventDao().upsert(
MarmotGroupEvent(
id = groupEvent.id,
createdAt = Instant.fromEpochSeconds(groupEvent.createdAt),
encryptedContent = groupEvent.encryptedContent(),
chatRoomId = localChatRoom.chatRoom.id,
nostrEventId = groupEvent.id,
userPublicKey = nostrSignerSync.pubKey,
publicKey = nostrSignerSync.pubKey
)
)
// Sync broadcast to all the required relays...
database.broadcastNostrEventRequestDao().insert(
Relays.DefaultDMRelayList.map { // TODO: Get these from localChatRoom...
BroadcastNostrEventRequest(
nostrEventId = groupEvent.id,
relayURL = it.url
)
}
)
}
companion object {
const val TAG = "NostrNip17Dao"
}
}

View File

@@ -339,7 +339,7 @@ abstract class NostrDao(
database.profileDao().upsert(profile)
}
// TODO: Support MLSGroupEvent handling...
// MLSGroupEvent handling...
nostrEvent.toGroupEvent(
userPublicKey = activeKeyPair.pubKey.toHex()
)?.let { groupEvent ->
@@ -624,7 +624,8 @@ abstract class NostrDao(
// Sync ChatMessageRelayListEvent publicKey...
// TODO: Get relayHint form participant...
if (profilePublicKeysToSync.containsKey(
if (
profilePublicKeysToSync.containsKey(
relayURL
)
) {

View File

@@ -1,38 +1,23 @@
package at.torch.compose.database.repository
import at.torch.compose.database.TorchDatabase
import at.torch.compose.database.model.MarmotInnerEvent
import at.torch.compose.database.model.MarmotKeyPackageBundle
import at.torch.compose.database.model.UnsignedNostrEvent
import at.torch.compose.exceptions.MarmotMissingChatGroupException
import at.torch.compose.extensions.exporterSecret
import at.torch.compose.extensions.toHex
import at.torch.compose.nostr.Relays
import at.torch.compose.repository.MarmotRepository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.GroupEventResult
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager.Companion.KEY_PACKAGE_LIFETIME_SECONDS
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils
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.codec.TlsWriter
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.marmot.mls.crypto.X25519
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.DecryptedMessage
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.group.RetainedEpochSecrets
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule
import com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree
import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
import com.vitorpamplona.quartz.marmot.mls.tree.Extension
@@ -42,8 +27,12 @@ import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlin.io.encoding.Base64
import kotlin.time.Instant
@@ -112,6 +101,43 @@ class DatabaseMarmotRepository(
return database.marmotKeyPackageBundleDao().getAllMarmotKeyPackageBundles(publicKey)
}
override suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow<MarmotInnerEvent?> {
return database.marmotInnerEventDao().observeUnprocessedMarmotInnerEvents(publicKey)
}
override suspend fun encryptAndSendMarmotInnerEvent(
marmotInnerEvent: MarmotInnerEvent,
nostrSignerSync: NostrSignerSync
) {
database.chatRoomDao().findChatRoomById(marmotInnerEvent.chatRoomId)?.let { localChatRoom ->
localChatRoom.chatRoom.mlsGroupState?.let {
val mlsGroup = MlsGroup.restore(
MlsGroupState.decodeTls(
localChatRoom.chatRoom.mlsGroupState.hexToByteArray()
)
)
val innerEvent = RumorAssembler.assembleRumor(
pubKey = nostrSignerSync.pubKey,
ev = EventTemplate(
createdAt = marmotInnerEvent.createdAt.epochSeconds,
content = marmotInnerEvent.content,
tags = marmotInnerEvent.tags,
kind = marmotInnerEvent.kind
)
)
database.marmotOutboundDao().encryptAndSendMarmotInnerEvent(
localChatRoom = localChatRoom,
mlsGroup = mlsGroup,
innerEvent = innerEvent,
nostrSignerSync = nostrSignerSync
)
}
}
}
private fun generateKeyPackage(
identity: ByteArray,
dTagSlot: String = KeyPackageUtils.PRIMARY_SLOT,

View File

@@ -1,11 +1,10 @@
package at.torch.compose.repository
import at.torch.compose.database.model.MarmotInnerEvent
import at.torch.compose.database.model.MarmotKeyPackageBundle
import com.vitorpamplona.quartz.marmot.GroupEventResult
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlinx.coroutines.flow.Flow
interface MarmotRepository {
@@ -16,6 +15,10 @@ interface MarmotRepository {
suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List<MarmotKeyPackageBundle>
suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow<MarmotInnerEvent?>
suspend fun encryptAndSendMarmotInnerEvent(marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync)
companion object {
val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE = object: MarmotRepository {
override suspend fun publishMarmotKeyPackageBundle(
@@ -28,6 +31,17 @@ interface MarmotRepository {
override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List<MarmotKeyPackageBundle> {
return emptyList()
}
override suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow<MarmotInnerEvent?> {
TODO("Not yet implemented")
}
override suspend fun encryptAndSendMarmotInnerEvent(
marmotInnerEvent: MarmotInnerEvent,
nostrSignerSync: NostrSignerSync
) {
TODO("Not yet implemented")
}
}
}
}

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import at.torch.compose.repository.MarmotRepository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -25,6 +26,7 @@ class NotaryViewModel(
val activeWalletStateFlow: StateFlow<ActiveWallet?>,
val nostrRepository: at.torch.compose.repository.NostrRepository,
val chatRepository: at.torch.compose.repository.ChatRepository,
val marmotRepository: MarmotRepository,
val scope: CoroutineScope,
): ViewModel() {
@@ -36,6 +38,7 @@ class NotaryViewModel(
activeWalletStateFlow: StateFlow<ActiveWallet?>,
nostrRepository: at.torch.compose.repository.NostrRepository,
chatRepository: at.torch.compose.repository.ChatRepository,
marmotRepository: MarmotRepository,
scope: CoroutineScope,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
@@ -43,6 +46,7 @@ class NotaryViewModel(
activeWalletStateFlow = activeWalletStateFlow,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
marmotRepository = marmotRepository,
scope = scope,
)
}
@@ -63,6 +67,7 @@ class NotaryViewModel(
observeUnsignedNostrEvents(keyPair)
observeUnsealedGiftWrapPayloads(keyPair)
observeUnprocessedMarmotInnerEvents(keyPair)
}
}
}
@@ -138,4 +143,29 @@ class NotaryViewModel(
}
}
private fun observeUnprocessedMarmotInnerEvents(
keyPair: KeyPair
) {
val tempSigner = NostrSignerSync(
keyPair
)
scope.launch(Dispatchers.IO) {
logger.i { "observeUnsealedGiftWrapPayloads: ${keyPair.pubKey.toHexKey()}" }
marmotRepository.observeUnprocessedMarmotInnerEvents(
publicKey = keyPair.pubKey.toHexKey()
).distinctUntilChanged().collect { marmotInnerEventOrNull ->
scope.launch(Dispatchers.IO) {
marmotInnerEventOrNull?.let { marmotInnerEvent ->
logger.d("encrypt and broadcast: $marmotInnerEvent")
marmotRepository.encryptAndSendMarmotInnerEvent(
marmotInnerEvent,
nostrSignerSync = tempSigner
)
}
}
}
}
}
}