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:
Kgothatso Ngako
2026-09-05 19:01:50 +02:00
parent 296011dcd5
commit 1700e6d899
4 changed files with 127 additions and 15 deletions

View File

@@ -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 ->

View File

@@ -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.
*

View File

@@ -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")

View File

@@ -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")
}