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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user