From 5e93a39a6c44423828121b44f305b6e76a1bde06 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 23 Jun 2026 13:02:54 +0200 Subject: [PATCH] Marmot ingestion and all --- .../at/torch/compose/database/dao/NostrDao.kt | 39 +++- .../database/model/MarmotGroupEvent.kt | 123 ++++++++++ .../database/model/MarmotInnerEvent.kt | 211 ++++++++++++++++++ .../database/model/MarmotKeyPackageBundle.kt | 3 + ...MissingRetainedExporterSecretsException.kt | 3 + .../MarmotWelcomeMismatchedGroupId.kt | 4 - ...MarmotWelcomeMismatchedGroupIdException.kt | 4 + 7 files changed, 371 insertions(+), 16 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotGroupEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotInnerEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotMissingRetainedExporterSecretsException.kt delete mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotWelcomeMismatchedGroupId.kt create mode 100644 composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotWelcomeMismatchedGroupIdException.kt 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 d9ef3e7f..8c8ad47e 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 @@ -21,7 +21,7 @@ import at.torch.compose.exceptions.GiftWrapUnsealException import at.torch.compose.exceptions.MarmotMissingKeyPackageEventIdException import at.torch.compose.exceptions.MarmotMissingNostrGroupDataExtension import at.torch.compose.exceptions.MarmotNoMatchingKeyPackageBundleException -import at.torch.compose.exceptions.MarmotWelcomeMismatchedGroupId +import at.torch.compose.exceptions.MarmotWelcomeMismatchedGroupIdException import at.torch.compose.extensions.toHex import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -41,7 +42,6 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlin.io.encoding.Base64 -import kotlin.math.sign import kotlin.time.Clock import kotlin.time.Instant @@ -324,6 +324,7 @@ abstract class NostrDao( } // TODO: Support MLSGroupEvent handling... + nostrEvent.toGiftWrapMessageWithReceiverPTag()?.let { giftWrapMessage -> logger.d("giftWrapMessage: $giftWrapMessage") val giftWrapReceiverProfile = database.profileDao().getProfileByPublicKey(giftWrapMessage.receiverPublicKey) @@ -472,7 +473,7 @@ abstract class NostrDao( } if (derivedId != chatRoomId) { - throw MarmotWelcomeMismatchedGroupId( + throw MarmotWelcomeMismatchedGroupIdException( "The hint group Id $chatRoomId doesn't match the derived group id $derivedId" ) } @@ -490,16 +491,30 @@ abstract class NostrDao( ) ) - // TODO: Add participants... by processing group.members() - - val participants = giftWrapPayload.participantPTags( - NormalizedRelayUrl(relayURL) // TODO: Get relayURL for publicKey profile... - ).map { - Participant( - participantPublicKey = it.pubKey, - chatRoomId = chatRoomId, - relayHint = it.relayHint?.url + database.marmotKeyPackageBundleDao().upsert( + marmotKeyPackageBundle.copy( + consumed = true ) + ) + + // TODO: Add participants... by processing group.members() + val normalizedRelayUrl = NormalizedRelayUrl(relayURL) + + val participants = group.members().mapNotNull { (leafIndex, leafNode) -> + val pubkey = + when (val cred = leafNode.credential) { + is Credential.Basic -> cred.identity.toHexKey() + else -> null + } + if (pubkey != null) { + Participant( + participantPublicKey = pubkey, + chatRoomId = chatRoomId, + relayHint = normalizedRelayUrl.url + ) + } else { + null + } } // Sync missing participant profiles... diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotGroupEvent.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotGroupEvent.kt new file mode 100644 index 00000000..11eb78a5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotGroupEvent.kt @@ -0,0 +1,123 @@ +package at.torch.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.PrimaryKey +import at.torch.compose.database.model.traits.BroadcastableEntity +import at.torch.compose.database.model.traits.LocalStoreEntity +import at.torch.compose.database.model.traits.NostrEventEntity +import at.torch.compose.database.model.traits.SoftDeletableEntity +import at.torch.compose.database.model.traits.TimestampedEntity +import at.torch.compose.exceptions.MarmotMissingRetainedExporterSecretsException +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.HexKey +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.Log +import kotlin.time.Clock +import kotlin.time.Instant + +@Entity( + foreignKeys = [ + + ], +) +data class MarmotGroupEvent( + @PrimaryKey + val id: HexKey, + + /** + * Sender Public Key + */ + val userPublicKey: String, + + val chatRoomId: String, + + val encryptedContent: String, + val expiresAt: Instant? = null, + + /** + * What gets broadcasts? + */ + override val nostrEventId: String, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = Clock.System.now(), + override val savedAt: Instant = Clock.System.now(), + override val deletedAt: Instant? = null, + override val broadcastedAt: Instant? = null, +) : NostrEventEntity, TimestampedEntity, LocalStoreEntity, BroadcastableEntity, SoftDeletableEntity { + + companion object { + const val KIND = GroupEvent.KIND + + fun fromGroupEvent( + groupEvent: GroupEvent, + userPublicKey: String + ): MarmotGroupEvent? { + return groupEvent.groupId()?.let { chatRoomId -> + MarmotGroupEvent( + id = groupEvent.id, + encryptedContent = groupEvent.encryptedContent(), + nostrEventId = groupEvent.id, + createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + expiresAt = groupEvent.expiration()?.let { Instant.fromEpochSeconds(it) } + ) + } + } + } + + fun toMarmotInnerEvent(group: MlsGroup): MarmotInnerEvent? { + val mlsBytes = + return null; + } + + /** + * Decrypt the outer ChaCha20-Poly1305 layer, trying the current epoch's + * exporter key first and falling back to retained epoch exporter keys. + * + * After a commit advances the epoch, late-arriving messages encrypted + * with the previous epoch's exporter key would fail without this fallback. + * + * Returns null when neither the current epoch key nor any retained key + * decrypts. This happens normally for commits/application messages from + * epochs that predate our join (we never held those keys), so callers + * should treat null as an expected "nothing to do here" outcome and log + * at DEBUG, not as an error. + */ + private fun tryDecryptOuterLayer( + group: MlsGroup, + encryptedContent: String, + ): ByteArray? { + // Try current epoch key first + try { + val exporterKey = group.exporterSecret( + "marmot", + "group-event".encodeToByteArray(), + 32, + ) + return GroupEventEncryption.decrypt(encryptedContent, exporterKey) + } catch (e: Exception) { + // Current epoch key failed — try retained epoch keys + Log.e("MarmotGroupEvent", "Current Epoch Key Failed", e) + } + + // Try retained epoch exporter keys (most recent first) + throw MarmotMissingRetainedExporterSecretsException( + "Torch currently doesn't support retainedExporterSecrets" + ) +// val retainedKeys = groupManager.retainedExporterSecrets(groupId) +// for (retainedKey in retainedKeys) { +// try { +// return GroupEventEncryption.decrypt(encryptedContent, retainedKey) +// } catch (_: Exception) { +// // This retained key didn't work — try the next one +// } +// } +// +// return null + } +} \ 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 new file mode 100644 index 00000000..d30afcf2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotInnerEvent.kt @@ -0,0 +1,211 @@ +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 = [ + ForeignKey( + entity = MarmotGroupEvent::class, + parentColumns = ["id"], + childColumns = ["marmotGroupEventId"], + onDelete = ForeignKey.CASCADE, + ) + ], +) +data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + /** + * Sender Public Key + */ + val publicKey: String, + + val chatRoomId: String, + + /** + * Payload Event Kind + */ + val kind: Kind = ChatEvent.KIND, + + /** + * Tags for the unsigned giftwrap + * + * [ + * ["p", "", ""], + * ["p", "", ""], + * ["e", "", ""] // if this is a reply + * ["subject", ""], + * // rest of tags... + * ] + * + * q tags MAY be used when citing events in the .content with NIP-21. + */ + val tags: Array>, + + /** + * content MUST be plain text. Fields id and created_at are required. + */ + val content: String, + val quotedEventId: String? = null, + + /** + * Associated MarmotGroupEvent + */ + val marmotGroupEventId: String? = null, + + /** + * 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 = "GiftWrapPayload" + + /** + * Compute the NIP-40 expiration timestamp for outbound application messages, + * or `null` when the group does not have disappearing messages configured. + * + * Per MIP-01/MIP-03, when `disappearing_message_secs` is set in the Marmot + * Group Data Extension, clients MUST auto-apply an `expiration` tag on + * kind:445 events at `created_at + disappearing_message_secs`. + */ + fun disappearingExpiration( + group: MlsGroup, + createdAt: Long, + ): Long? { + val extensions = group.extensions ?: return null + val marmotData = MarmotGroupData.fromExtensions(extensions) ?: return null + val secs = marmotData.disappearingMessageSecs ?: return null + return createdAt + secs.toLong() + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as MarmotInnerEvent + + if (id != other.id) return false + if (kind != other.kind) return false + if (publicKey != other.publicKey) return false + if (chatRoomId != other.chatRoomId) return false + if (!tags.contentDeepEquals(other.tags)) return false + if (content != other.content) return false + if (quotedEventId != other.quotedEventId) return false + if (marmotGroupEventId != other.marmotGroupEventId) 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 = id.hashCode() + result = 31 * result + kind + result = 31 * result + publicKey.hashCode() + result = 31 * result + chatRoomId.hashCode() + result = 31 * result + tags.contentDeepHashCode() + result = 31 * result + content.hashCode() + result = 31 * result + (quotedEventId?.hashCode() ?: 0) + result = 31 * result + (marmotGroupEventId?.hashCode() ?: 0) + 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 + } + + suspend fun toSignedNostrEvent(group: MlsGroup): GroupEvent { + // TODO: Assert group.id == chatRoomId + + val innerEventTemplate = eventTemplate( + kind = kind, + description = content + ) + + val rumorEvent = RumorAssembler.assembleRumor( + publicKey, + innerEventTemplate + ) + + val mlsCiphertext = group.encrypt(rumorEvent.toJson().encodeToByteArray()) + + val exporterKey = group.exporterSecret( + "marmot", + "group-event".encodeToByteArray(), + 32 + ) + val encryptedContent = GroupEventEncryption.encrypt(mlsCiphertext, exporterKey) + + val createdAt = TimeUtils.now() + val expirationTime = disappearingExpiration(group, createdAt) + val template = + GroupEvent.build( + encryptedContentBase64 = encryptedContent, + nostrGroupId = chatRoomId, + createdAt = createdAt, + ) { + if (expirationTime != null) expiration(expirationTime) + } + + // Step 4: Sign with a fresh ephemeral keypair + val ephemeralSigner = NostrSignerInternal(KeyPair()) + val signedEvent: GroupEvent = ephemeralSigner.sign(template) + + return GroupEvent( + id = signedEvent.id, + content = signedEvent.content, + createdAt = signedEvent.createdAt, + tags = signedEvent.tags, + sig = signedEvent.sig, + pubKey = signedEvent.pubKey + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotKeyPackageBundle.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotKeyPackageBundle.kt index 78d54c84..ed175b13 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotKeyPackageBundle.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/MarmotKeyPackageBundle.kt @@ -37,6 +37,9 @@ data class MarmotKeyPackageBundle( val ncryptsecEncryptionPrivateKey: String, val ncryptsecSignaturePrivateKey: String, + val consumed: Boolean = false, + val rotated: Boolean = false, // TODO: Should we have rotatedByID + override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = Clock.System.now(), override val savedAt: Instant = Clock.System.now(), diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotMissingRetainedExporterSecretsException.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotMissingRetainedExporterSecretsException.kt new file mode 100644 index 00000000..c33815ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotMissingRetainedExporterSecretsException.kt @@ -0,0 +1,3 @@ +package at.torch.compose.exceptions + +class MarmotMissingRetainedExporterSecretsException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotWelcomeMismatchedGroupId.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotWelcomeMismatchedGroupId.kt deleted file mode 100644 index 9bd297df..00000000 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotWelcomeMismatchedGroupId.kt +++ /dev/null @@ -1,4 +0,0 @@ -package at.torch.compose.exceptions - -class MarmotWelcomeMismatchedGroupId(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotWelcomeMismatchedGroupIdException.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotWelcomeMismatchedGroupIdException.kt new file mode 100644 index 00000000..567f22c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/exceptions/MarmotWelcomeMismatchedGroupIdException.kt @@ -0,0 +1,4 @@ +package at.torch.compose.exceptions + +class MarmotWelcomeMismatchedGroupIdException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { +} \ No newline at end of file