test: cover the two decisions that decide who said what

The crypto was tested; the logic that acts on it was not. Both untested
pieces were the security-critical ones, and neither fails loudly when it
goes wrong -- one silently widens who may impersonate whom, the other
silently destroys a message.

Extracted MarmotDirectMessage.classify, which decides what an arriving
wrap is to this device, from ChatMessage.directMessage, which turns that
decision into rows. The decision is pure; only the filing needs a
database, and Room-backed code cannot be unit-tested in this project. Same
split, and for the same reason, as pulling the wrap/open crypto out of the
DAO in the first place.

Extracted MarmotInboundManager.mip03Rejection for the same reason. Its
kind:1059 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. There is now a test that walks seven kinds and
asserts each is still held to MIP-03.

Fifteen cases, the ones worth naming:

`our own message is ours, even though we cannot open it` and `ours is
decided before anything is opened`. A sender cannot decrypt their own wrap
-- the key was discarded -- so by decryption alone this is
indistinguishable from a bystander's view, and only the MLS identity
separates them. Get it wrong and the inbound path files an empty
placeholder over the row sendChatMessage wrote, which holds the only copy
of those words. It is the one failure here that loses data rather than
rendering something wrong.

`words sealed by one member and sent by another are dropped`. The check
that replaces MIP-03 for this kind, tested directly rather than described
in a comment as it was before.

One test asserts something I had wrong. I expected a seal relabelled with
another member's pubkey to be caught by the signature check; it never
reaches it. NIP-44 derives the conversation key from the pubkey being
claimed, so 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. The outcome is Unreadable, which is the truth: the recipient
genuinely cannot read it. `a seal tampered with after signing is dropped`
covers what verify() does catch, using an alteration that survives
decryption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 23:17:43 +02:00
parent 635cef9311
commit a74a4b71cf
5 changed files with 566 additions and 138 deletions

View File

@@ -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
)
}
/**

View File

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

View File

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

View File

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

View File

@@ -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<MarmotDirectMessage.Delivery.Readable>(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<MarmotDirectMessage.Delivery.Rejected>(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<MarmotDirectMessage.Delivery.Rejected>(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<MarmotDirectMessage.Delivery.Rejected>(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<MarmotDirectMessage.Delivery.Rejected>(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<MarmotDirectMessage.Delivery.Rejected>(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<SealedRumorEvent>(
createdAt = at,
kind = SealedRumorEvent.KIND,
tags = emptyArray(),
content = sealedBy.nip44Encrypt(rumor.toJson(), to),
)
return GiftWrapEvent.create(event = seal, recipientPubKey = to, createdAt = at)
}
}