feat: read a direct message, or say one was sent

Completes the inbound half. A member now files one of three things when a
kind:1059 arrives as an application payload, and which one depends only on
whether their key opens it.

The carve-out first. MarmotInboundManager rejects any inner event whose
pubkey is not the MLS sender's credential identity -- MIP-03, and the check
that stops a member minting events attributed to somebody else. A gift wrap
is keyed to a throwaway key by construction and names nobody, so it cannot
satisfy a check about its author; kind:1059 is now exempt.

The check is not weakened, it is relocated. What replaces it is
`seal.pubKey == senderIdentity` on a seal whose signature verifies -- a
signature bound to an MLS leaf, rather than a plaintext field compared to
one. It is strictly harder to forge: the attack it stops is a member
re-wrapping a seal they were legitimately sent and passing it off to a
third party as its author's, and that fails here because the MLS frame
says who actually sent this one.

senderIdentity also stops being optional. It was previously only compared;
now every sender-derived field reads from it, because the payload carries
no author at all. A leaf with no identity is an error rather than a
mismatch.

Attribution is resolved in NostrDao and handed to fromGroupEventResult,
rather than added to GroupEventResult.ApplicationMessage where it belongs.
quartz is a binary dependency here (com.vitorpamplona.quartz:quartz:1.14.0)
and the local checkout is a reference copy, not a build input, so the
result type cannot gain a field without publishing a fork. NostrDao holds
the group, the leaf index is already on the result, and an application
message advances no epoch, so the tree has not moved by the time it reads
it. Same value, no fork.

The three outcomes:

The recipient opens the wrap and gets the words. The rumor is stored as its
own MarmotInnerEvent keyed on the rumor's id -- the id the sender queued --
so both sides of the conversation hold one message under one identity. The
wrap keeps its own row as the wire artifact.

A bystander gets a line with no content. That is the feature working: the
group is meant to see that a private message was sent and to whom, and
nothing else.

The sender, on a re-sync, is indistinguishable from a bystander, because
the wrap's key was discarded and we cannot open our own message. Left
unguarded this files an empty placeholder over the row sendChatMessage
wrote -- which is the only copy of those words anywhere. Hence the early
return on senderIdentity == us, mirroring the guard
NostrDao.persistInboundChatMessage already carries on the NIP-17 path.

A failed validation drops the message and logs rather than throwing. The
caller is inside storeNostrEvent's transaction, and a forged direct message
should cost its own line, not the whole event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 19:04:39 +02:00
parent 1700e6d899
commit 5ae974517b
3 changed files with 187 additions and 2 deletions

View File

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

View File

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

View File

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