From cb129bb0c629b392dacc7ecbb606b367c2393673 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Mon, 6 Jul 2026 12:55:36 +0200 Subject: [PATCH] Add marmot commit result --- .../database/dao/MarmotCommitResultDao.kt | 15 ++ .../compose/database/dao/MarmotOutboundDao.kt | 117 ++++++++------- .../compose/database/model/GiftWrapPayload.kt | 2 +- .../database/model/MarmotCommitResult.kt | 136 ++++++++++++++++++ .../database/model/MarmotInnerEvent.kt | 2 +- 5 files changed, 220 insertions(+), 52 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotCommitResultDao.kt create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotCommitResult.kt diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotCommitResultDao.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotCommitResultDao.kt new file mode 100644 index 00000000..6c2c8a53 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotCommitResultDao.kt @@ -0,0 +1,15 @@ +package at.torch.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import at.torch.compose.database.model.MarmotCommitResult +import at.torch.compose.database.model.MarmotKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.PublicKey + +@Dao +interface MarmotCommitResultDao { + @Upsert + suspend fun upsert(marmotCommitResult: MarmotCommitResult) +} \ 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 index dc1100c6..630f7b84 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotOutboundDao.kt @@ -9,6 +9,7 @@ import at.torch.compose.database.model.ChatMessageBroadcastNostrEventRequestRela import at.torch.compose.database.model.ChatMessageNostrEventRelation import at.torch.compose.database.model.ChatRoom import at.torch.compose.database.model.GiftWrapPayload +import at.torch.compose.database.model.MarmotCommitResult import at.torch.compose.database.model.MarmotGroupEvent import at.torch.compose.database.model.MarmotInnerEvent import at.torch.compose.database.model.MarmotInnerEvent.Companion.disappearingExpiration @@ -27,12 +28,10 @@ import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.marmot.mls.tree.Credential -import com.vitorpamplona.quartz.nip01Core.core.Event 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.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -41,7 +40,6 @@ import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.io.bytestring.encode import kotlin.io.encoding.Base64 import kotlin.time.Instant @@ -58,7 +56,6 @@ abstract class MarmotOutboundDao( peerKeyPackage: MarmotKeyPackage, relays: List = Relays.DefaultDMRelayList.map { it.url } ): String { - // TODO: Might move this logic to a marmotOutboudDao for consistency... val groupId = RandomInstance.bytes(32).toHexKey() val mlsGroup = MlsGroup.create( identity = userPublicKey.hexToByteArray(), @@ -91,10 +88,11 @@ abstract class MarmotOutboundDao( userPublicKey = userPublicKey, peerPublicKey = peerPublicKey, peerKeyPackage = peerKeyPackage, - relays = relays + relays = relays, + isOneMemberInitialGroupCreation = true ) - // TODO: Update chatRoom + // Update chatRoom database.chatRoomDao().upsert( chatRoom.copy( mlsGroupState = mlsGroup.saveState().encodeTls().toHex(), @@ -110,7 +108,8 @@ abstract class MarmotOutboundDao( userPublicKey: HexKey, peerPublicKey: HexKey, peerKeyPackage: MarmotKeyPackage, - relays: List = Relays.DefaultDMRelayList.map { it.url } + relays: List = Relays.DefaultDMRelayList.map { it.url }, + isOneMemberInitialGroupCreation: Boolean ) { val keyPackage = MlsKeyPackage.decodeTls( TlsReader( @@ -162,6 +161,22 @@ abstract class MarmotOutboundDao( ) ) + // TODO: Save commitResult... in case we need to broadcast welcomeEvent after relay acknowledgement... +// database.marmotCommitResultDao().upsert( +// MarmotCommitResult( +// id = commitEvent.id, +// isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation, +// chatRoomId = groupId, +// commitBytes = commitResult.commitBytes, +// preCommitExporterSecret = commitResult.preCommitExporterSecret, +// welcomeBytes = commitResult.welcomeBytes, +// framedCommitBytes = commitResult.preCommitExporterSecret, +// groupInfoBytes = commitResult.groupInfoBytes, +// userPublicKey = userPublicKey, +// createdAt = Instant.fromEpochSeconds(commitEvent.createdAt) +// ) +// ) + database.broadcastNostrEventRequestDao().insert( relays.map { BroadcastNostrEventRequest( @@ -171,50 +186,52 @@ abstract class MarmotOutboundDao( } ) - // Send welcome event to participant - commitResult.welcomeBytes?.let { welcomeBytes -> - val welcomeBase64 = Base64.encode( - source = welcomeBytes - ) - - // GiftWrap... - val welcomeEvent = WelcomeEvent.build( - welcomeBase64 = welcomeBase64, - keyPackageEventId = peerKeyPackage.id, - relays = relays.map { NormalizedRelayUrl(it) }, - nostrGroupId = groupId, - ) - // Compute welcomeEventId - val welcomeEventId = EventHasher.hashId( - pubKey = userPublicKey, - kind = welcomeEvent.kind, - tags = welcomeEvent.tags, - createdAt = welcomeEvent.createdAt, - content = welcomeEvent.content - ) - - GiftWrapPayload( - id = welcomeEventId, - publicKey = userPublicKey, - chatRoomId = groupId, - kind = welcomeEvent.kind, - tags = welcomeEvent.tags, - content = welcomeEvent.content, - createdAt = Instant.fromEpochSeconds(welcomeEvent.createdAt) - ) - - // Save chatMessage for the invite... - database.chatMessageDao().upsert( - ChatMessage( - content = "Invited participant to chat", // use profile.humanReadable... - chatRoomId = groupId, - senderPublicKey = userPublicKey, - isUserMessage = true, // TODO: This is information message... - giftWrapPayloadId = welcomeEventId, - marmotGroupEventId = null, - marmotInnerEventId = null + if (isOneMemberInitialGroupCreation) { + // Send welcome event to participant + commitResult.welcomeBytes?.let { welcomeBytes -> + val welcomeBase64 = Base64.encode( + source = welcomeBytes ) - ) + + // GiftWrap... + val welcomeEvent = WelcomeEvent.build( + welcomeBase64 = welcomeBase64, + keyPackageEventId = peerKeyPackage.id, + relays = relays.map { NormalizedRelayUrl(it) }, + nostrGroupId = groupId, + ) + // Compute welcomeEventId + val welcomeEventId = EventHasher.hashId( + pubKey = userPublicKey, + kind = welcomeEvent.kind, + tags = welcomeEvent.tags, + createdAt = welcomeEvent.createdAt, + content = welcomeEvent.content + ) + + GiftWrapPayload( + id = welcomeEventId, + publicKey = userPublicKey, + chatRoomId = groupId, + kind = welcomeEvent.kind, + tags = welcomeEvent.tags, + content = welcomeEvent.content, + createdAt = Instant.fromEpochSeconds(welcomeEvent.createdAt) + ) + + // Save chatMessage for the invite... + database.chatMessageDao().upsert( + ChatMessage( + content = "Invited participant to chat", // use profile.humanReadable... + chatRoomId = groupId, + senderPublicKey = userPublicKey, + isUserMessage = true, // TODO: This is information message... + giftWrapPayloadId = welcomeEventId, + marmotGroupEventId = null, + marmotInnerEventId = null + ) + ) + } } diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/GiftWrapPayload.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/GiftWrapPayload.kt index 3f81f084..2c78c2c5 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/GiftWrapPayload.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/GiftWrapPayload.kt @@ -24,7 +24,7 @@ import kotlin.time.Instant ) ], ) -data class GiftWrapPayload( // TODO: Rename this to GiftWrapPayload... +data class GiftWrapPayload( @PrimaryKey val id: HexKey, diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotCommitResult.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotCommitResult.kt new file mode 100644 index 00000000..b97a7661 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotCommitResult.kt @@ -0,0 +1,136 @@ +package at.torch.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Ignore +import androidx.room3.PrimaryKey +import at.torch.compose.database.model.traits.LocalStoreEntity +import at.torch.compose.database.model.traits.SoftDeletableEntity +import at.torch.compose.database.model.traits.TimestampedEntity +import at.torch.compose.database.model.traits.UserViewableEntity +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +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.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers +import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.time.Clock +import kotlin.time.Instant + +@Entity( + foreignKeys = [ + + ], +) +data class MarmotCommitResult( // TODO: Rename this to GiftWrapPayload... + @PrimaryKey + val id: HexKey, + + val userPublicKey: HexKey, + + val chatRoomId: HexKey, + + val isOneMemberInitialGroupCreation: Boolean, + + /** + * commitBytes is the raw TLS-encoded Commit struct (RFC 9420 §12.4) + */ + val commitBytes: ByteArray, + val welcomeBytes: ByteArray?, + val groupInfoBytes: ByteArray?, + /** + * Fully-framed commit ready for the MIP-03 outer ChaCha20 encryption. + * Wire format: MlsMessage(version=mls10, wireFormat=mls_public_message, payload=PublicMessage(...)). + */ + val framedCommitBytes: ByteArray = commitBytes, + /** + * `MLS-Exporter("marmot", "group-event", 32)` evaluated at the **pre-commit** + * epoch (N) — the key the group had when this commit was computed, before + * local state advanced to N+1. Publishers of the kind:445 MUST ChaCha20-wrap + * the commit with this key (RFC 9420 §12.4 and MDK parity). Using the + * post-commit (N+1) key makes the commit unreadable to existing members + * still at epoch N — the exact scenario that caused Eden to stall at + * epoch 1 and never decrypt any of David's subsequent messages. + * + * Empty by default for the test-only entry points that don't need it. + */ + val preCommitExporterSecret: ByteArray = ByteArray(0), + + /** + * Current time + */ + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt, + override val savedAt: Instant = Clock.System.now(), + override val viewedAt: Instant? = null, + override val deletedAt: Instant? = null, // Who this message should be seen by (used to derive conversationId) +): TimestampedEntity, + LocalStoreEntity, + UserViewableEntity, + SoftDeletableEntity { + @Ignore + private val logger = Logger.withTag(TAG) + + + companion object { + const val TAG = "MarmotCommitResult" + + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as MarmotCommitResult + + if (isOneMemberInitialGroupCreation != other.isOneMemberInitialGroupCreation) return false + if (id != other.id) return false + if (userPublicKey != other.userPublicKey) return false + if (chatRoomId != other.chatRoomId) return false + if (!commitBytes.contentEquals(other.commitBytes)) return false + if (!welcomeBytes.contentEquals(other.welcomeBytes)) return false + if (!groupInfoBytes.contentEquals(other.groupInfoBytes)) return false + if (!framedCommitBytes.contentEquals(other.framedCommitBytes)) return false + if (!preCommitExporterSecret.contentEquals(other.preCommitExporterSecret)) return false + if (createdAt != other.createdAt) return false + if (updatedAt != other.updatedAt) return false + if (savedAt != other.savedAt) return false + if (viewedAt != other.viewedAt) return false + if (deletedAt != other.deletedAt) return false + if (logger != other.logger) return false + + return true + } + + override fun hashCode(): Int { + var result = isOneMemberInitialGroupCreation.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + userPublicKey.hashCode() + result = 31 * result + chatRoomId.hashCode() + result = 31 * result + commitBytes.contentHashCode() + result = 31 * result + (welcomeBytes?.contentHashCode() ?: 0) + result = 31 * result + (groupInfoBytes?.contentHashCode() ?: 0) + result = 31 * result + framedCommitBytes.contentHashCode() + result = 31 * result + preCommitExporterSecret.contentHashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + updatedAt.hashCode() + result = 31 * result + savedAt.hashCode() + result = 31 * result + (viewedAt?.hashCode() ?: 0) + result = 31 * result + (deletedAt?.hashCode() ?: 0) + result = 31 * result + logger.hashCode() + return result + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotInnerEvent.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotInnerEvent.kt index 6f97f269..9b193aea 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotInnerEvent.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotInnerEvent.kt @@ -99,7 +99,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... companion object { - const val TAG = "GiftWrapPayload" + const val TAG = "MarmotInnerEvent" /** * Compute the NIP-40 expiration timestamp for outbound application messages,