diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt new file mode 100644 index 00000000..5b58bf32 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt @@ -0,0 +1,164 @@ +package press.mantra.compose.nostr + +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.crypto.KeyPair +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.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * A one-to-one message carried inside a Marmot group, as the application payload of a + * kind:445 group event. See docs/marmot-direct-messages.md. + * + * The payload is a stock NIP-59 gift wrap: a throwaway-keyed kind:1059 around a + * sender-signed kind:13 seal around the kind:14 rumor that holds the words. Every member + * of the group decrypts the MLS layer and sees the wrap; only the recipient can open it. + * + * Everything here is a pure function of its arguments, with no database and no MLS state, + * which is what makes it testable — Room-backed code cannot be unit-tested in this project. + * + * **Nothing built here may ever be broadcast.** It is a genuine, correctly signed NIP-59 + * gift wrap, indistinguishable from one the NIP-17 path would be right to publish, so the + * only thing keeping it off a relay is the tables it is kept out of: a Marmot direct + * message lives in `MarmotInnerEvent`, never in `GiftWrapPayload` or `GiftWrapMessage`. + */ +object MarmotDirectMessage { + /** + * A wrap that opened, both layers of it. + * + * The seal is returned alongside the rumor because it is the only layer that names + * the sender, and the caller has to bind it to the MLS sender identity before it + * believes a word — see [MarmotDirectMessage] and the inbound path. + */ + data class Opened( + val seal: Event, + val rumor: Event, + ) + + /** + * Builds the payload for a direct message to [recipientPublicKey]. + * + * [createdAt] is used unfuzzed on all three layers. NIP-59 randomises the wrap and + * the seal by up to two days to frustrate correlation at a relay, and both + * `GiftWrapEvent.create` and `SealedRumorEvent.create` default to that; there is no + * relay at this layer, the kind:445 around it already carries the true time, and + * fuzzing would only scatter the "sent a private message" line up to two days out of + * position in every other member's transcript. + * + * The rumor is assembled from exactly the fields the caller queued, so its id is the + * one the recipient computes after unwrapping — and, on the way out, the one the + * outbound path uses to find the chat message to link and broadcast. + */ + fun wrap( + signer: NostrSignerSync, + recipientPublicKey: HexKey, + kind: Kind, + createdAt: Long, + tags: Array>, + content: String, + recipientRelayHint: NormalizedRelayUrl? = null, + ): GiftWrapEvent { + val rumor = rumor(signer.pubKey, kind, createdAt, tags, content) + + // The only layer that names the sender, and the only one they sign. Everything + // the recipient believes about who wrote this rests on this signature. + // + // Built by hand rather than through SealedRumorEvent.create, which is suspend, + // takes a NostrSigner rather than the sync signer the outbound path holds, and + // defaults createdAt to the two-day fuzz. + val seal = + signer.signNormal( + createdAt = createdAt, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt(rumor.toJson(), recipientPublicKey), + ) + + // create() mints and discards its own random key -- "GiftWrap is always a random + // key" -- which is the point: nothing in the wrap names the sender. Who sent it + // comes from the MLS frame instead, which is authenticated and cannot be forged. + return GiftWrapEvent.create( + event = seal, + recipientPubKey = recipientPublicKey, + createdAt = createdAt, + recipientRelayHint = recipientRelayHint, + ) + } + + /** + * The rumor a direct message carries, unsigned and unencrypted. + * + * Exposed so the outbound queue can compute a row id before it has a signer, and so + * tests can assert that the id the recipient arrives at is the id the sender queued. + */ + fun rumor( + senderPublicKey: HexKey, + kind: Kind, + createdAt: Long, + tags: Array>, + content: String, + ): Event = + RumorAssembler.assembleRumor( + pubKey = senderPublicKey, + ev = + EventTemplate( + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + ), + ) + + /** + * Opens [wrap] with [me]'s key, or returns null if it was not addressed to them. + * + * Trying is the test: the `p` tag is a routing hint, not the authority, and the wrap + * either decrypts with our key or it does not. Null covers every way that can fail -- + * somebody else's message, a malformed payload, a layer that is not the kind it + * claims -- because the caller is inside inbound processing for a kind:445 that may + * carry a perfectly good message for somebody, and a throw would abandon all of it. + * + * **The sender cannot open their own wrap.** The throwaway key is discarded at send + * time and nothing can reconstruct it, so a sender's own message returns null here + * too. That is a property, not a defect: see docs/marmot-direct-messages.md. Do not + * "fix" it by storing the throwaway private key. + */ + fun open( + wrap: Event, + me: KeyPair, + ): Opened? { + if (wrap.kind != GiftWrapEvent.KIND) return null + val privateKey = me.privKey ?: return null + + val seal = decryptEvent(wrap.content, privateKey, wrap.pubKey) ?: return null + if (seal.kind != SealedRumorEvent.KIND) return null + + val rumor = decryptEvent(seal.content, privateKey, seal.pubKey) ?: return null + + return Opened(seal = seal, rumor = rumor) + } + + private fun decryptEvent( + ciphertext: String, + privateKey: ByteArray, + fromPublicKey: HexKey, + ): Event? = + try { + Event.fromJsonOrNull( + Nip44.decrypt( + payload = ciphertext, + privateKey = privateKey, + pubKey = fromPublicKey.hexToByteArray(), + ), + ) + } catch (_: Exception) { + null + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt new file mode 100644 index 00000000..1d3d2474 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt @@ -0,0 +1,185 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The envelope a direct message travels in, run against real secp256k1 and real NIP-44. + * + * Two of these assert properties that read like bugs and are not. The wrap names nobody, + * so the group can only learn who sent it from the MLS frame around it; and the sender + * cannot reopen their own message, because the key that sealed it was discarded. Both are + * consequences of using a throwaway key, both are documented in + * docs/marmot-direct-messages.md, and both are here so that a later change which quietly + * reverses them fails rather than ships. + */ +class MarmotDirectMessageTest { + private val alice = NostrSignerSync(KeyPair()) + private val bob = KeyPair() + private val eve = KeyPair() + + private val bobPublicKey = bob.pubKey.toHexKey() + private val at = 1_700_000_000L + private val text = "the vote is at six, do not tell the room" + + private fun aliceWrapsForBob(content: String = text): GiftWrapEvent = + MarmotDirectMessage.wrap( + signer = alice, + recipientPublicKey = bobPublicKey, + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = content, + ) + + @Test + fun `the recipient reads what the sender wrote`() { + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob), "Bob could not open a wrap addressed to him") + + assertEquals(text, opened.rumor.content) + assertEquals(alice.pubKey, opened.rumor.pubKey) + assertEquals(ChatMessageEvent.KIND, opened.rumor.kind) + } + + @Test + fun `the sender cannot reopen their own message`() { + // The throwaway key is gone, so this is unrecoverable by construction. Asserted + // rather than merely documented, because the obvious "fix" -- persisting the + // throwaway private key -- would be strictly worse than the identity-keyed wrap + // this design was chosen over, and would reintroduce the attribution the + // throwaway key exists to remove. + assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), alice.keyPair)) + } + + @Test + fun `a bystander gets nothing, and no exception`() { + // Null rather than a throw: the caller is midway through processing a kind:445 + // that carries a real message for somebody, and an exception would abandon it. + assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), eve)) + } + + @Test + fun `the wrap names nobody`() { + val first = aliceWrapsForBob() + val second = aliceWrapsForBob() + + assertNotEquals(alice.pubKey, first.pubKey, "the wrap is keyed to its sender") + assertNotEquals(bobPublicKey, first.pubKey, "the wrap is keyed to its recipient") + assertNotEquals(first.pubKey, second.pubKey, "the throwaway key is being reused") + + // Signed by the throwaway key, per NIP-59. The signature attributes nothing -- + // the signer is meaningless and discarded -- and keeping it is what makes this a + // real gift wrap that GiftWrapEvent.create builds and unwrapOrNull opens. + assertTrue(first.verify(), "the wrap does not verify against its own key") + assertEquals(GiftWrapEvent.KIND, first.kind) + } + + @Test + fun `the seal is what binds the words to their author`() { + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob)) + + assertEquals(SealedRumorEvent.KIND, opened.seal.kind) + // The inbound path checks exactly this pair against the MLS sender identity. It + // is what replaces the MIP-03 pubkey check for kind:1059, so if the seal stops + // being signed by the sender, the carve-out becomes a hole. + assertEquals(alice.pubKey, opened.seal.pubKey) + assertTrue(opened.seal.verify(), "the seal's signature does not verify") + assertEquals(opened.seal.pubKey, opened.rumor.pubKey) + } + + @Test + fun `a seal from somebody else is still opened, and is caught by its pubkey`() { + // open() decrypts; it does not adjudicate. Eve can seal her own rumor to Bob and + // wrap it, and Bob's key will open it -- what stops it being rendered as Alice's + // is the inbound check that the seal's pubkey is the MLS sender's identity. This + // test pins the half open() is responsible for: the pubkey it reports is Eve's. + val eveSigner = NostrSignerSync(eve) + val forged = + MarmotDirectMessage.wrap( + signer = eveSigner, + recipientPublicKey = bobPublicKey, + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = "alice here, send the funds", + ) + + val opened = assertNotNull(MarmotDirectMessage.open(forged, bob)) + + assertEquals(eveSigner.pubKey, opened.seal.pubKey) + assertNotEquals(alice.pubKey, opened.seal.pubKey) + } + + @Test + fun `the rumor id is the one the sender queued`() { + val tags = arrayOf(PTag.assemble(bobPublicKey, null)) + + // What the outbound queue computes before it has a signer, and what it later uses + // to find the chat message to link and broadcast. If these ever diverge the + // message is encrypted, stored, and silently never sent. + val queued = + EventHasher.hashId( + alice.pubKey, + at, + ChatMessageEvent.KIND, + tags, + text, + ) + + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob)) + + assertEquals(queued, opened.rumor.id) + assertEquals(queued, MarmotDirectMessage.rumor(alice.pubKey, ChatMessageEvent.KIND, at, tags, text).id) + } + + @Test + fun `no layer is fuzzed into the past`() { + val wrap = aliceWrapsForBob() + val opened = assertNotNull(MarmotDirectMessage.open(wrap, bob)) + + // NIP-59 defaults every one of these to randomWithTwoDays(). Inside MLS that only + // scatters the bystander line up to two days out of position in every other + // member's transcript, so all three carry the real message time. + assertEquals(at, wrap.createdAt) + assertEquals(at, opened.seal.createdAt) + assertEquals(at, opened.rumor.createdAt) + } + + @Test + fun `the recipient is on the wrap where the group can see it`() { + val wrap = aliceWrapsForBob() + + // Deliberate: it is what lets a bystander's transcript say who the message was + // for. Removing it would hide the recipient from the group at the cost of the + // named line -- see the decisions table in docs/marmot-direct-messages.md. + assertEquals(bobPublicKey, wrap.tags.firstTagValue("p")) + } + + @Test + fun `garbage in the wrap does not become a message`() { + val notAWrap = + alice.signNormal( + createdAt = at, + kind = GiftWrapEvent.KIND, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = "not nip-44 at all", + ) + + assertNull(MarmotDirectMessage.open(notAWrap, bob)) + } +}