diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/TorchDatabase.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/TorchDatabase.kt index 18176923..d0aedaf4 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/TorchDatabase.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/TorchDatabase.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotInnerEventDao.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotInnerEventDao.kt index d58c3ffd..4d85558b 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotInnerEventDao.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotInnerEventDao.kt @@ -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 + @Upsert suspend fun upsert(marmotInnerEvent: MarmotInnerEvent) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotOutboundDao.kt new file mode 100644 index 00000000..f13ac732 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotOutboundDao.kt @@ -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" + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrDao.kt index 1b668cf1..d9e36461 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrDao.kt @@ -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 ) ) { diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt index b4225c66..28526243 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseMarmotRepository.kt @@ -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 { + 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, diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt index 4d4e8a54..ba8aa694 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/MarmotRepository.kt @@ -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 + suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow + + 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 { return emptyList() } + + override suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow { + TODO("Not yet implemented") + } + + override suspend fun encryptAndSendMarmotInnerEvent( + marmotInnerEvent: MarmotInnerEvent, + nostrSignerSync: NostrSignerSync + ) { + TODO("Not yet implemented") + } } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NotaryViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NotaryViewModel.kt index 281efd4f..08361750 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NotaryViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/NotaryViewModel.kt @@ -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, 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, 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 + ) + } + } + } + } + } + } \ No newline at end of file