diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 304a048c..694dc176 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -445,6 +445,13 @@ abstract class NostrDao( activeKeyPair = activeKeyPair, groupEvent = groupEvent, groupEventResult = groupEventResult, + // Who sent it comes from the MLS frame, not the + // payload. Resolved here because this is where the + // group is; the leaf index is on the result, and the + // tree has not moved -- an application message + // advances no epoch. + senderIdentity = (groupEventResult as? GroupEventResult.ApplicationMessage) + ?.let { mlsGroup.memberIdentityHex(it.senderLeafIndex) }, )?.let { chatMessage -> logger.d("chatMessage: $chatMessage") database.chatMessageDao().upsert( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 61435d4f..1f54f41f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -28,6 +28,12 @@ import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent import press.mantra.compose.nostr.nip30303.TranslationContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationEvent +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import press.mantra.compose.nostr.MarmotDirectMessage import kotlin.time.Clock import kotlin.time.Instant @@ -210,11 +216,151 @@ data class ChatMessage( TYPE_DKG_FAILED, ) + private val logger = Logger.withTag("ChatMessage") + + /** + * Files one direct message, from whichever side of it this device is on. + * + * Three outcomes, and the third is the one that destroys data if it is missed: + * + * **The recipient** opens the wrap and gets the words. The rumor inside is stored + * as its own [MarmotInnerEvent] -- keyed on the rumor's id, which is the id the + * sender queued -- and the chat message points at that rather than at the wrap. + * + * **A bystander** cannot open it and gets a line with no content: the group is + * meant to see that a private message was sent and to whom, and nothing more. + * + * **The sender**, on a re-sync, is indistinguishable from a bystander -- the + * wrap's key was discarded at send time, so we cannot open our own message -- and + * so would file an empty placeholder over the row `sendChatMessage` wrote on the + * way out. That row is the only copy of those words that exists. Hence the early + * return; `NostrDao.persistInboundChatMessage` carries the same guard, for the + * same reason, on the NIP-17 path. + * + * See docs/marmot-direct-messages.md. + */ + private suspend fun directMessage( + database: MantraDatabase, + activeKeyPair: KeyPair, + groupEvent: GroupEvent, + chatRoomId: String, + wrap: Event, + senderIdentity: HexKey? + ): ChatMessage? { + if (senderIdentity == null) { + logger.e("Dropping direct message ${wrap.id}: no MLS sender identity to attribute it to") + return null + } + + val recipientPublicKey = wrap.tags.firstTagValue("p") + + // Our own words coming back. See the third outcome above. + if (senderIdentity == activeKeyPair.pubKey.toHex()) { + logger.d("Direct message ${wrap.id} is ours; the row written on send is the only copy") + return null + } + + val opened = MarmotDirectMessage.open(wrap, activeKeyPair) + + if (opened == null) { + // Relays redeliver and negentropy re-syncs; the wrap yields the same id + // every time, so this is what keeps a second delivery from becoming a + // second line. ChatMessage.id is autogenerated and would take a duplicate. + if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(wrap.id) != null) { + return null + } + + return ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = wrap.id, + senderPublicKey = senderIdentity, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(wrap.createdAt), + content = "" + ) + } + + // What replaces MIP-03's pubkey check for kind:1059, and the only thing + // standing between the words and being rendered as somebody else's. The seal + // is the one layer the sender signs; binding it to the MLS leaf that sent the + // message is what stops a member re-wrapping a seal they were sent and + // passing it off as its author's. A forged one is dropped, not rendered. + if (opened.seal.pubKey != senderIdentity) { + logger.e( + "Dropping direct message ${wrap.id}: sealed by ${opened.seal.pubKey} " + + "but sent by $senderIdentity" + ) + return null + } + + if (!opened.seal.verify()) { + logger.e("Dropping direct message ${wrap.id}: the seal's signature does not verify") + return null + } + + if (opened.rumor.pubKey != opened.seal.pubKey) { + logger.e( + "Dropping direct message ${wrap.id}: rumor by ${opened.rumor.pubKey} " + + "inside a seal by ${opened.seal.pubKey}" + ) + return null + } + + if (opened.rumor.kind != ChatMessageEvent.KIND) { + logger.w("Dropping direct message ${wrap.id}: unsupported rumor kind ${opened.rumor.kind}") + return null + } + + if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(opened.rumor.id) != null) { + return null + } + + // Keyed on the rumor, not the wrap. This is the id the sender queued, so both + // sides of the conversation hold the same message under the same identity. + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + id = opened.rumor.id, + publicKey = opened.rumor.pubKey, + marmotGroupEventId = groupEvent.id, + tags = opened.rumor.tags, + content = opened.rumor.content, + chatRoomId = chatRoomId, + kind = opened.rumor.kind, + createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt) + ) + ) + + return ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = opened.rumor.id, + senderPublicKey = senderIdentity, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt), + content = opened.rumor.content + ) + } + + /** + * [senderIdentity] is the MLS credential identity of the leaf that sent this, + * resolved by the caller, which holds the group. It is not derivable from the + * payload: a direct message's wrap is keyed to a throwaway key and names nobody, + * and even for kinds that do carry a pubkey the MLS frame is the authenticated + * source while the field is merely asserted. + */ suspend fun fromGroupEventResult( database: MantraDatabase, activeKeyPair: KeyPair, groupEvent: GroupEvent, - groupEventResult: GroupEventResult + groupEventResult: GroupEventResult, + senderIdentity: HexKey? = null ): ChatMessage? { return when(groupEventResult) { is GroupEventResult.ApplicationMessage -> { @@ -507,6 +653,16 @@ data class ChatMessage( ) } } + GiftWrapEvent.KIND -> { + directMessage( + database = database, + activeKeyPair = activeKeyPair, + groupEvent = groupEvent, + chatRoomId = groupEventResult.groupId, + wrap = event, + senderIdentity = senderIdentity + ) + } else -> { ChatMessage( giftWrapPayloadId = null, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt index c7be3dba..1cf9bdfc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Profile @@ -267,7 +268,28 @@ object MarmotInboundManager { if (innerEvent != null) { val senderIdentity = mlsGroup.memberIdentityHex(decrypted.senderLeafIndex) - if (senderIdentity == null || innerEvent.pubKey != senderIdentity) { + + // Required rather than merely compared. Every sender-derived field + // downstream now reads from here -- who a message is from, and whether + // it is ours -- because a gift wrap payload carries no author of its + // own. Without an identity there is nothing to attribute a line to. + if (senderIdentity == null) { + return GroupEventResult.Error( + groupId, + "No MLS credential identity for sender leaf ${decrypted.senderLeafIndex}", + ) + } + + // A gift wrap's pubkey is a throwaway key by construction, so there is + // no author field here to compare against and MIP-03's check cannot be + // applied as written. The authorship claim moves inside, to the signed + // kind:13 seal, which ChatMessage.fromGroupEventResult binds to this + // same senderIdentity before it renders a word -- a verified signature + // bound to an MLS leaf, rather than a plaintext field compared to one. + // + // Other Marmot clients do not have this carve-out and will drop these + // as impersonation. See docs/marmot-direct-messages.md. + if (innerEvent.kind != GiftWrapEvent.KIND && innerEvent.pubKey != senderIdentity) { return GroupEventResult.Error( groupId, "MIP-03: inner event pubkey (${innerEvent.pubKey}) does not match MLS sender identity ($senderIdentity)",