diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt index ca3413e3..dba928bb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt @@ -22,6 +22,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.extensions.exporterSecret import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.MarmotInboundManager.EPOCH_RETENTION_WINDOW +import press.mantra.compose.nostr.MarmotDirectMessage import press.mantra.compose.nostr.Relays import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag @@ -33,6 +34,7 @@ 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 @@ -545,7 +547,20 @@ abstract class MarmotOutboundDao( marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync ): GroupEvent { - val innerEvent = RumorAssembler.assembleRumor( + // A direct message is gift wrapped for one member before it goes into MLS, so + // what the group carries is a kind:1059 whose id is not this row's. Everything + // downstream -- MLS encrypt, outer ChaCha20, kind:445, persistence, broadcast -- + // is the same either way. See MarmotDirectMessage. + val innerEvent: Event = marmotInnerEvent.directMessageRecipientPublicKey?.let { recipient -> + MarmotDirectMessage.wrap( + signer = nostrSignerSync, + recipientPublicKey = recipient, + kind = marmotInnerEvent.kind, + createdAt = marmotInnerEvent.createdAt.epochSeconds, + tags = marmotInnerEvent.tags, + content = marmotInnerEvent.content + ) + } ?: RumorAssembler.assembleRumor( pubKey = nostrSignerSync.pubKey, ev = EventTemplate( createdAt = marmotInnerEvent.createdAt.epochSeconds, @@ -628,11 +643,25 @@ abstract class MarmotOutboundDao( database.marmotInnerEventDao().upsert( marmotInnerEvent.copy( - marmotGroupEventId = groupEvent.id + marmotGroupEventId = groupEvent.id, + // A direct message's plaintext is already on its ChatMessage row, which is + // the sender's only copy of it. A second one here would be cleartext left + // in a table that otherwise holds nothing but wire events. + content = if (marmotInnerEvent.directMessageRecipientPublicKey != null) { + "" + } else { + marmotInnerEvent.content + } ) ) - val chatMessageOrNull = database.chatMessageDao().getChatMessagesByMarmotInnerEventId(innerEvent.id) + // Keyed on the queued row, not on `innerEvent.id`. For a direct message those + // differ -- the row is the rumor, the wire event is the wrap built around it -- + // and the wrap's id matches no ChatMessage, so this lookup would come back null, + // the message would never be linked, and no BroadcastNostrEventRequest would ever + // be inserted. Encrypted, stored, and silently never sent. They are the same value + // for every other kind of message. + val chatMessageOrNull = database.chatMessageDao().getChatMessagesByMarmotInnerEventId(marmotInnerEvent.id) logger.d("chatMessageOrNull: $chatMessageOrNull") chatMessageOrNull?.let { chatMessage -> 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 0391ad42..61435d4f 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 @@ -94,6 +94,19 @@ data class ChatMessage( ): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity { companion object { + /** + * A one-to-one message inside a group, as a line in the group's chat. + * + * Written by both sides of the room. For its two parties [content] is the words; + * for everybody else it is empty, and the line says only that a private message + * was sent and to whom -- which is all the group can learn, and all it should. + * + * [directMessageRecipientPublicKey] is set on every one of these regardless of + * who can read it, because naming the recipient is the point of the line the + * bystanders get. See docs/marmot-direct-messages.md. + */ + const val TYPE_DIRECT_MESSAGE = "directMessage" + /** * A ChillDKG ritual message, as a line in the group's chat. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index b12e3723..4eacf017 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -173,24 +173,58 @@ class DatabaseChatRepository( override suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind + messageType: Kind, + directMessageRecipientPublicKey: HexKey? ) { logger.i("sendChatMessage($text): $localChatRoom") val mlsGroup = localChatRoom.chatRoom.toMlsGroup() if (mlsGroup != null) { - // Create ChatRumor... - val createdAt = Clock.System.now().epochSeconds - val marmotInnerEventKind = if (messageType == ChatMessageEvent.KIND) { - ChatEvent.KIND // nip-17 chatMessageEvent.kind is in MLS chatMessage.kind - } else { - messageType + // Resolved before anything is written. A recipient outside the room cannot be + // sent to -- the wrap would be undecryptable by every member including them, + // while the group still saw that a private message had gone somewhere -- and + // the room is also where the relay hint for the `p` tag comes from. + val directMessageRecipient = directMessageRecipientPublicKey?.let { recipient -> + require(recipient != localChatRoom.chatRoom.userPublicKey) { + "Cannot send a direct message to yourself: the wrap's throwaway key means it could never be read back" + } + + localChatRoom.localParticipants + .map { it.participant } + .firstOrNull { it.participantPublicKey == recipient } + ?: throw IllegalArgumentException( + "Cannot send a direct message to $recipient: not a member of ${localChatRoom.chatRoom.id}" + ) } + // Create ChatRumor... + val createdAt = Clock.System.now().epochSeconds + val marmotInnerEventKind = when { + // A direct message is a NIP-17 chat message that happens to travel inside + // the group, so it keeps kind:14 instead of being mapped down to the + // group's kind:9. That is also what tells the two apart on the way back in. + directMessageRecipient != null -> ChatMessageEvent.KIND + messageType == ChatMessageEvent.KIND -> ChatEvent.KIND // nip-17 chatMessageEvent.kind is in MLS chatMessage.kind + else -> messageType + } + + val marmotInnerEventTags = directMessageRecipient?.let { recipient -> + arrayOf( + PTag.assemble( + recipient.participantPublicKey, + recipient.relayHint?.let { NormalizedRelayUrl(it) } + ) + ) + } ?: emptyArray() + + // For a direct message the queued row IS the rumor the wrap will carry, so + // this id is the one the recipient arrives at after unwrapping -- and, on the + // way out, the one the notary uses to find this chat message again. The wrap + // built around it has its own id, which is why that lookup keys on the row. val marmotInnerEventId = EventHasher.hashId( pubKey = localChatRoom.chatRoom.userPublicKey, createdAt = createdAt, - tags = emptyArray(), + tags = marmotInnerEventTags, content = text, kind = marmotInnerEventKind ) @@ -200,10 +234,11 @@ class DatabaseChatRepository( id = marmotInnerEventId, publicKey = localChatRoom.chatRoom.userPublicKey, createdAt = Instant.fromEpochSeconds(createdAt), - tags = emptyArray(), + tags = marmotInnerEventTags, content = text, chatRoomId = localChatRoom.chatRoom.id, - kind = marmotInnerEventKind + kind = marmotInnerEventKind, + directMessageRecipientPublicKey = directMessageRecipient?.participantPublicKey ) ) @@ -216,6 +251,15 @@ class DatabaseChatRepository( giftWrapPayloadId = null, marmotGroupEventId = null, marmotInnerEventId = marmotInnerEventId, + // The sender's only copy: the wrap is sealed with a key that is + // discarded, so nothing can reconstruct these words from the + // transcript later, on this device or any other. + messageType = if (directMessageRecipient != null) { + ChatMessage.TYPE_DIRECT_MESSAGE + } else { + "message" + }, + directMessageRecipientPublicKey = directMessageRecipient?.participantPublicKey ) ) } else { @@ -280,6 +324,21 @@ class DatabaseChatRepository( giftWrapPayload: GiftWrapPayload, nostrSignerSync: NostrSignerSync ) { + // An MLS room must never produce a NIP-17 gift wrap. Its messages already travel + // inside kind:445, and its direct messages are real, correctly signed NIP-59 wraps + // -- indistinguishable from something this path would be right to publish. The + // only thing keeping one off a relay is that it never becomes a GiftWrapPayload, + // so refuse here too rather than trusting every future caller to know that. + // See docs/marmot-direct-messages.md. + val chatRoom = database.chatRoomDao().findChatRoomById(giftWrapPayload.chatRoomId) + if (chatRoom?.chatRoom?.mlsGroupState != null) { + logger.e( + "Refusing to seal payload ${giftWrapPayload.id}: ${giftWrapPayload.chatRoomId} is an MLS room, " + + "and sealing would broadcast it to relays" + ) + return + } + database.participantDao().findParticipantsByChatRoomId(giftWrapPayload.chatRoomId).forEach { participant -> if (giftWrapPayload.kind == WelcomeEvent.KIND) { logger.i("Only giftWrap welcomeEvent payload (${giftWrapPayload.id}) to the participant who published the related keyPackage") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 4669fae9..1f4a3894 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -79,10 +79,20 @@ interface ChatRepository { peers: List>, ): List + /** + * Sends [text] to the room, or -- when [directMessageRecipientPublicKey] is given -- + * to that one member of it. + * + * A direct message still travels as one group event that every member receives and + * can see the shape of; only its contents are private. See + * docs/marmot-direct-messages.md. NIP-17 rooms do not support it: there is no group + * for the rest of to see it, so a private message there is just a message. + */ suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind = ChatMessageEvent.KIND + messageType: Kind = ChatMessageEvent.KIND, + directMessageRecipientPublicKey: HexKey? = null ) fun updateChatRoomSubject(publicKey: String, subject: String): ChatRoom? @@ -192,7 +202,8 @@ interface ChatRepository { override suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind + messageType: Kind, + directMessageRecipientPublicKey: HexKey? ) { // TODO("Not yet implemented") }