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:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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].
|
||||
*
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user