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 5d040b11..b99ad908 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 @@ -30,8 +30,6 @@ 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 @@ -221,23 +219,8 @@ data class 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. + * The decision is [MarmotDirectMessage.classify]'s, which is where it is tested; + * this turns it into rows. See docs/marmot-direct-messages.md. */ private suspend fun directMessage( database: MantraDatabase, @@ -247,105 +230,91 @@ data class ChatMessage( 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 when (val delivery = MarmotDirectMessage.classify(wrap, activeKeyPair, senderIdentity)) { + // Our own words coming back. We cannot open our own wrap -- its key was + // discarded at send time -- so this is indistinguishable from a + // bystander's view by decryption alone, and filing it as one would + // replace the words on the row `sendChatMessage` wrote with an empty + // placeholder. That row is the only copy of them that exists. + // `NostrDao.persistInboundChatMessage` carries the same guard, for the + // same reason, on the NIP-17 path. + MarmotDirectMessage.Delivery.Ours -> { + logger.d("Direct message ${wrap.id} is ours; the row written on send is the only copy") + 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 = "" - ) + // A forged or malformed message costs its own line and nothing else. It + // is dropped rather than thrown because the caller is inside + // storeNostrEvent's transaction, and this must not cost the whole event. + is MarmotDirectMessage.Delivery.Rejected -> { + logger.e("Dropping direct message ${wrap.id}: ${delivery.reason}") + null + } + + // Not ours to read. The group is meant to see that a private message was + // sent and to whom, and nothing more. + MarmotDirectMessage.Delivery.Unreadable -> { + // 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 + } + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = wrap.id, + senderPublicKey = senderIdentity ?: wrap.pubKey, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(wrap.createdAt), + content = "" + ) + } + + is MarmotDirectMessage.Delivery.Readable -> { + val opened = delivery.opened + + 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) + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = opened.rumor.id, + senderPublicKey = opened.seal.pubKey, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt), + content = opened.rumor.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 - ) } /** diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt index 1cf9bdfc..af284650 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt @@ -37,6 +37,7 @@ import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.MarmotDirectMessage import press.mantra.compose.nostr.Relays import kotlin.time.Clock @@ -62,6 +63,45 @@ object MarmotInboundManager { private val commitTracker = CommitOrdering.EpochCommitTracker() + /** + * Why an inner application event may not be accepted from this sender, or null. + * + * MIP-03: an inner event's `pubkey` MUST equal the MLS sender's credential identity, + * so a member cannot mint events claiming a different author. + * + * A gift wrap is exempt. Its pubkey is a throwaway key by construction -- see + * [MarmotDirectMessage] -- so there is no author field to compare and the check + * cannot be applied as written. The authorship claim moves inside, to the signed + * kind:13 seal, which [MarmotDirectMessage.classify] binds to this same identity + * before a word is rendered: a verified signature bound to an MLS leaf, rather than a + * plaintext field compared to one. + * + * The exemption is on kind:1059 alone. Widening it to any other kind, or dropping the + * kind guard, hands every member the ability to publish events as anybody. + * + * Other Marmot clients do not have this carve-out and drop these messages as + * impersonation. See docs/marmot-direct-messages.md. + * + * [senderIdentity] is required rather than merely compared: every sender-derived + * field downstream reads from it -- who a message is from, and whether it is ours -- + * because a gift wrap payload carries no author of its own. + */ + fun mip03Rejection( + innerEventKind: Int, + innerEventPubKey: HexKey, + senderIdentity: HexKey?, + ): String? { + if (senderIdentity == null) { + return "No MLS credential identity for the sender leaf" + } + + if (innerEventKind != GiftWrapEvent.KIND && innerEventPubKey != senderIdentity) { + return "MIP-03: inner event pubkey ($innerEventPubKey) does not match MLS sender identity ($senderIdentity)" + } + + return null + } + suspend fun processGroupMembershipChanges( database: MantraDatabase, localChatRoom: LocalChatRoom, @@ -269,31 +309,8 @@ object MarmotInboundManager { if (innerEvent != null) { val senderIdentity = mlsGroup.memberIdentityHex(decrypted.senderLeafIndex) - // 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)", - ) + mip03Rejection(innerEvent.kind, innerEvent.pubKey, senderIdentity)?.let { reason -> + return GroupEventResult.Error(groupId, reason) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt index 5b58bf32..4a7fc810 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt @@ -4,11 +4,14 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -42,6 +45,87 @@ object MarmotDirectMessage { val rumor: Event, ) + /** + * What this device should do with an arriving wrap. + * + * Separated from the filing of it so the decision can be tested: the persistence it + * drives needs a database, which cannot be unit-tested in this project, and the + * decision includes the check that replaces MIP-03 for kind:1059. That check is the + * only thing standing between somebody else's words and being rendered as a member's + * own, so it is not somewhere to rely on integration testing that does not exist. + */ + sealed interface Delivery { + /** + * Our own message coming back, which we cannot open -- the wrap's key was + * discarded at send time -- and so cannot tell apart from [Unreadable] by + * decryption alone. Only the MLS sender identity distinguishes them. + * + * File nothing: the row written on send holds the only copy of these words, and + * an unreadable line would replace them with a placeholder. + */ + data object Ours : Delivery + + /** Somebody else's. The group sees that it happened, and to whom. */ + data object Unreadable : Delivery + + /** Addressed to us, opened, and consistent with the leaf that sent it. */ + data class Readable( + val opened: Opened, + ) : Delivery + + /** Something is wrong with it. Drop it; do not render it as anybody's. */ + data class Rejected( + val reason: String, + ) : Delivery + } + + /** + * Decides what [wrap] is to this device, given the identity MLS says sent it. + * + * [senderIdentity] is not optional and is not read from the payload: a wrap names + * nobody, so the MLS frame is the only source of who sent it. Absent one there is + * nothing to attribute a line to and nothing to check the seal against. + */ + fun classify( + wrap: Event, + me: KeyPair, + senderIdentity: HexKey?, + ): Delivery { + if (senderIdentity == null) { + return Delivery.Rejected("no MLS sender identity to attribute it to") + } + + if (senderIdentity == me.pubKey.toHexKey()) return Delivery.Ours + + val opened = open(wrap, me) ?: return Delivery.Unreadable + + // The seal is the one layer the sender signs. Binding it to the leaf MLS says + // sent this message is what replaces MIP-03's pubkey check for kind:1059 -- + // a verified signature bound to a leaf, rather than a plaintext field compared + // to one. A divergence here is somebody passing off words as another member's. + if (opened.seal.pubKey != senderIdentity) { + return Delivery.Rejected( + "sealed by ${opened.seal.pubKey} but sent by $senderIdentity", + ) + } + + if (!opened.seal.verify()) { + return Delivery.Rejected("the seal's signature does not verify") + } + + if (opened.rumor.pubKey != opened.seal.pubKey) { + return Delivery.Rejected( + "rumor by ${opened.rumor.pubKey} inside a seal by ${opened.seal.pubKey}", + ) + } + + if (opened.rumor.kind != ChatMessageEvent.KIND) { + return Delivery.Rejected("unsupported rumor kind ${opened.rumor.kind}") + } + + return Delivery.Readable(opened) + } + /** * Builds the payload for a direct message to [recipientPublicKey]. * diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt new file mode 100644 index 00000000..f6059a92 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt @@ -0,0 +1,82 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The one hole deliberately left in MIP-03's author check, and its edges. + * + * MIP-03 requires an inner application event's pubkey to equal the MLS sender's credential + * identity, which is what stops a member minting events attributed to somebody else. A + * gift wrap cannot satisfy it -- its pubkey is a throwaway key that names nobody -- so + * kind:1059 is exempt, and the authorship claim moves to the signed seal inside. + * + * The exemption is the most dangerous line in this feature. Widened to another kind, or + * stripped of its kind guard, it hands every member of every group the ability to publish + * events as anybody, and nothing else in the pipeline would notice. These tests exist so + * that widening it fails here rather than in a group. + */ +class MarmotMip03CarveOutTest { + private val sender = "a".repeat(64) + private val somebodyElse = "b".repeat(64) + private val throwaway = "c".repeat(64) + + @Test + fun `an ordinary message from its own author is accepted`() { + assertNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, sender)) + } + + @Test + fun `an ordinary message claiming somebody else is rejected`() { + val reason = MarmotInboundManager.mip03Rejection(ChatEvent.KIND, somebodyElse, sender) + + assertNotNull(reason) + assertTrue(reason.startsWith("MIP-03"), "expected the MIP-03 rejection, got: $reason") + } + + @Test + fun `a gift wrap is accepted despite naming nobody`() { + // The carve-out. The wrap's pubkey is a throwaway key and matches no member, which + // is the point of it; what authenticates the sender is the MLS frame, and what + // authenticates the author is the seal inside. See MarmotDirectMessage.classify. + assertNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, sender)) + } + + @Test + fun `the carve-out does not extend to any other kind`() { + // Every kind carried as an application payload today, plus the seal and rumor + // kinds a gift wrap contains -- none of which should ever arrive unwrapped, and + // all of which would be a way to launder an author if the guard were widened. + val kinds = listOf( + ChatEvent.KIND, + ChatMessageEvent.KIND, + SealedRumorEvent.KIND, + GiftWrapEvent.KIND - 1, + GiftWrapEvent.KIND + 1, + 0, + 1, + ) + + kinds.forEach { kind -> + assertNotNull( + MarmotInboundManager.mip03Rejection(kind, somebodyElse, sender), + "kind $kind was allowed to claim an author it does not own", + ) + } + } + + @Test + fun `a sender with no credential identity is rejected, wrap or not`() { + // Required rather than merely compared: with no identity there is nothing to + // attribute a line to, and for a gift wrap there is nothing to check the seal + // against either. Both kinds must fail, not just the one that does a comparison. + assertNotNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, null)) + assertNotNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, null)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt new file mode 100644 index 00000000..ee4b8473 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt @@ -0,0 +1,276 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * What a device decides to do with an arriving wrap. + * + * This is where the check that replaces MIP-03 for kind:1059 is tested. The wrap itself + * names nobody, so everything a transcript says about who sent a private message rests on + * one comparison -- the seal's author against the identity MLS authenticated -- and if it + * stops being made, a member can have their words attributed to somebody else, or + * somebody else's attributed to them. The rest of the inbound path is Room-backed and + * cannot be unit-tested here, which is exactly why the decision was separated from the + * filing of it. + * + * `Ours` carries a second kind of risk. It is indistinguishable from `Unreadable` by + * decryption alone -- we cannot open our own wrap either -- and getting it wrong does not + * fail loudly: it files a placeholder over the only copy of the sender's own words. + */ +class MarmotDirectMessageDeliveryTest { + private val alice = NostrSignerSync(KeyPair()) + private val bob = KeyPair() + private val eve = NostrSignerSync(KeyPair()) + + private val alicePublicKey = alice.pubKey + private val bobPublicKey = bob.pubKey.toHexKey() + + private val at = 1_700_000_000L + private val text = "the vote is at six" + + private fun wrapFor( + signer: NostrSignerSync = alice, + recipient: String = bobPublicKey, + kind: Int = ChatMessageEvent.KIND, + content: String = text, + ): GiftWrapEvent = + MarmotDirectMessage.wrap( + signer = signer, + recipientPublicKey = recipient, + kind = kind, + createdAt = at, + tags = arrayOf(PTag.assemble(recipient, null)), + content = content, + ) + + @Test + fun `the recipient gets the words`() { + val delivery = MarmotDirectMessage.classify(wrapFor(), bob, alicePublicKey) + + val readable = assertIs(delivery) + assertEquals(text, readable.opened.rumor.content) + assertEquals(alicePublicKey, readable.opened.seal.pubKey) + } + + @Test + fun `a bystander gets a line with nothing in it`() { + val carol = KeyPair() + + assertEquals( + MarmotDirectMessage.Delivery.Unreadable, + MarmotDirectMessage.classify(wrapFor(), carol, alicePublicKey), + ) + } + + @Test + fun `our own message is ours, even though we cannot open it`() { + // The case that destroys data if it is missed. Alice cannot decrypt the wrap she + // sent -- its key was discarded -- so nothing about the payload distinguishes this + // from a bystander's view. Only the MLS sender identity does. Were it to come back + // Unreadable, the inbound path would file an empty line over the row sendChatMessage + // wrote, which is the only copy of those words anywhere. + val delivery = MarmotDirectMessage.classify(wrapFor(), alice.keyPair, alicePublicKey) + + assertEquals(MarmotDirectMessage.Delivery.Ours, delivery) + } + + @Test + fun `ours is decided before anything is opened`() { + // Same as above, from the other direction: a wrap Alice could never open, that is + // not even addressed to her, is still hers if MLS says she sent it. Nothing here + // may depend on decryption succeeding. + val toEve = wrapFor(recipient = eve.pubKey) + + assertEquals( + MarmotDirectMessage.Delivery.Ours, + MarmotDirectMessage.classify(toEve, alice.keyPair, alicePublicKey), + ) + } + + @Test + fun `a message with no sender identity is dropped`() { + // Attribution has no other source. A line that cannot say who sent it must not be + // rendered at all rather than be attributed to the wrap, which names nobody. + val delivery = MarmotDirectMessage.classify(wrapFor(), bob, null) + + assertIs(delivery) + } + + @Test + fun `words sealed by one member and sent by another are dropped`() { + // The check that replaces MIP-03. Eve seals to Bob and sends; if MLS says the + // sender was Alice, the two disagree about who is speaking and the message is not + // rendered as anybody's. Without this, the seal inside is free to claim an author + // the frame does not support. + val delivery = MarmotDirectMessage.classify(wrapFor(signer = eve), bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains(eve.pubKey) && rejected.reason.contains(alicePublicKey), + "the reason should name both parties to the disagreement: ${rejected.reason}", + ) + } + + @Test + fun `a seal relabelled with another author cannot even be opened`() { + // Eve's seal with Alice's pubkey written over it. This never reaches the author + // check, because NIP-44 derives the conversation key from the pubkey being + // claimed: relabelling a seal makes it undecryptable by the person it was + // encrypted for. The label is bound to the key, not merely asserted alongside it. + // + // So the outcome is Unreadable rather than Rejected -- Bob genuinely cannot read + // it -- and the group sees a private message it cannot open, which is the truth. + val forged = resealedAs(alicePublicKey, sealedBy = eve, to = bob) + + assertEquals( + MarmotDirectMessage.Delivery.Unreadable, + MarmotDirectMessage.classify(forged, bob, alicePublicKey), + ) + } + + @Test + fun `a seal tampered with after signing is dropped`() { + // Alice's own seal with its timestamp altered. Everything the previous test + // relies on still holds -- the pubkey is Alice's, so the content decrypts -- and + // the pubkey matches what MLS says, so the author check passes. Only the + // signature is left, and it is what catches this: the id no longer commits to the + // fields, so verify() fails. + val tampered = tamperedTimestamp(sealedBy = alice, to = bob) + + val delivery = MarmotDirectMessage.classify(tampered, bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("does not verify"), + "expected a signature rejection, got: ${rejected.reason}", + ) + } + + @Test + fun `a rumor by somebody other than the sealer is dropped`() { + // A correctly signed seal by Alice, wrapped around a rumor claiming to be Eve's. + // Everything outside the innermost layer checks out; the words would be filed + // under whoever the rumor names if this were not caught. + val mismatched = sealAroundForeignRumor(sealedBy = alice, rumorFrom = eve.pubKey, to = bobPublicKey) + + val delivery = MarmotDirectMessage.classify(mismatched, bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("inside a seal by"), + "expected a rumor/seal author mismatch, got: ${rejected.reason}", + ) + } + + @Test + fun `a rumor of the wrong kind is dropped`() { + // kind:9 is what an ordinary group message uses. Arriving gift wrapped it is not a + // direct message this version knows how to render, and rendering it as one would + // put a group message behind a lock. + val delivery = MarmotDirectMessage.classify(wrapFor(kind = ChatEvent.KIND), bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("${ChatEvent.KIND}"), + "expected the offending kind in the reason, got: ${rejected.reason}", + ) + } + + /** [sealedBy]'s seal with [claimedAuthor] written over its pubkey, wrapped for [to]. */ + private fun resealedAs( + claimedAuthor: String, + sealedBy: NostrSignerSync, + to: KeyPair, + ): GiftWrapEvent { + val honest = MarmotDirectMessage.wrap( + signer = sealedBy, + recipientPublicKey = to.pubKey.toHexKey(), + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = emptyArray(), + content = text, + ) + val seal = MarmotDirectMessage.open(honest, to)!!.seal + + val relabelled = + Event( + id = seal.id, + pubKey = claimedAuthor, + createdAt = seal.createdAt, + kind = SealedRumorEvent.KIND, + tags = seal.tags, + content = seal.content, + sig = seal.sig, + ) + + return GiftWrapEvent.create( + event = relabelled, + recipientPubKey = to.pubKey.toHexKey(), + createdAt = at, + ) + } + + /** [sealedBy]'s own seal, its timestamp altered after signing, wrapped for [to]. */ + private fun tamperedTimestamp( + sealedBy: NostrSignerSync, + to: KeyPair, + ): GiftWrapEvent { + val honest = MarmotDirectMessage.wrap( + signer = sealedBy, + recipientPublicKey = to.pubKey.toHexKey(), + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = emptyArray(), + content = text, + ) + val seal = MarmotDirectMessage.open(honest, to)!!.seal + + val altered = + Event( + id = seal.id, + pubKey = seal.pubKey, + createdAt = seal.createdAt + 86_400, + kind = SealedRumorEvent.KIND, + tags = seal.tags, + content = seal.content, + sig = seal.sig, + ) + + return GiftWrapEvent.create( + event = altered, + recipientPubKey = to.pubKey.toHexKey(), + createdAt = at, + ) + } + + /** A properly signed seal by [sealedBy] whose rumor claims [rumorFrom] wrote it. */ + private fun sealAroundForeignRumor( + sealedBy: NostrSignerSync, + rumorFrom: String, + to: String, + ): GiftWrapEvent { + val rumor = MarmotDirectMessage.rumor(rumorFrom, ChatMessageEvent.KIND, at, emptyArray(), text) + + val seal = + sealedBy.signNormal( + createdAt = at, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = sealedBy.nip44Encrypt(rumor.toJson(), to), + ) + + return GiftWrapEvent.create(event = seal, recipientPubKey = to, createdAt = at) + } +}