feat: send a direct message into the group, wrapped for one member
Completes the outbound half: a message with a recipient is queued as the rumor a gift wrap will carry, and the notary wraps it on its way into MLS. Everything downstream -- MLS encrypt, outer ChaCha20, kind:445, persistence, broadcast -- is untouched and does not know the difference. sendChatMessage resolves the recipient against the room before it writes anything. 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. Sending to yourself is refused for a different reason -- the wrap's key is discarded, so it could never be read back. A direct message keeps kind:14 rather than being mapped down to the group's kind:9 the way an ordinary message is. It is a NIP-17 chat message that happens to travel inside a group, and the kind is what tells the two apart on the way back in. Two things here are less arbitrary than they look: The queued row IS the rumor -- same kind, same tags, same content, same timestamp -- so its id is the one the recipient computes after unwrapping. That is the identity of the message on both sides. Which means the wire event's id is NOT the row's, and one existing lookup assumed it was. `getChatMessagesByMarmotInnerEventId(innerEvent.id)` now keys on the queued row instead. Left alone, a direct message's wrap id would match no ChatMessage, the lookup would come back null, and no BroadcastNostrEventRequest would ever be inserted -- encrypted, persisted, and silently never sent, with no error anywhere. The two ids are the same value for every other kind of message, so nothing else changes behaviour. The plaintext is scrubbed from the queued row once it has been sent. It is already on the ChatMessage row, which is the sender's only copy; a second one would be cleartext left behind in a table that otherwise holds nothing but wire events. sealGiftWrapPayload now refuses any payload belonging to an MLS room. That path is the one way a gift wrap reaches a relay -- the notary watches for payloads with no seal, seals them, and broadcasts -- and a Marmot direct message is a real, correctly signed NIP-59 wrap, indistinguishable from something this path would be right to publish. Keeping direct messages out of GiftWrapPayload is what makes them unbroadcastable; this refuses at the other end too, rather than trusting every future caller to know that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ->
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -79,10 +79,20 @@ interface ChatRepository {
|
||||
peers: List<Pair<HexKey, MarmotKeyPackage>>,
|
||||
): List<HexKey>
|
||||
|
||||
/**
|
||||
* 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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user