From 03c5aee9da765eb3cc7957eb990a7f1ee06cfa4d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 22:53:52 +0000 Subject: [PATCH] test(marmot): close MIP assertion gaps surfaced by spec review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked every @Test in the 8 MIP-level Marmot test files against the MIP-00/01/02/03/04 specs and closed the four gaps where tests either asserted too weakly or omitted a MUST requirement: MIP-00 — hex encoding + mls_ciphersuite required ------------------------------------------------- `KeyPackageUtilsTest` only tested a generic non-base64 encoding ("raw"). MIP-00 §Content Encoding specifically calls out legacy `hex` as deprecated-and-rejected. Added an explicit hex-encoding rejection test. `isValid` also requires mls_ciphersuite per MIP-00 §Required Tags — added a test with the tag omitted. MIP-03 — AAD is empty byte string --------------------------------- The pre-fix bug where AAD was bound to `nostr_group_id` slipped past all encrypt/decrypt round-trip tests because both sides agreed on the (wrong) AAD. Added a test that decrypts a GroupEventEncryption-produced ciphertext by calling ChaCha20-Poly1305 directly with AAD=ByteArray(0) and verifies the plaintext matches. Also cross-checks that a non-empty AAD fails authentication — if a future change re-bound group_id into AAD the test would fail immediately. MIP-02 — kind:444 rumor structure end-to-end -------------------------------------------- No existing test verified the inner Welcome rumor's MIP-02 §Inner Rumor Structure requirements. Added an end-to-end test that unwraps a real gift wrap (kind:1059 → kind:13 → kind:444) on the recipient side and asserts all MUST fields: kind == 444, sig == "" (rumor unsigned by design), ["encoding","base64"] tag present, ["e",] tag present, and ["relays", ...] tag present. MIP-03 — kind:445 h-tag format ------------------------------ No existing test locked in the `h` tag's format. MIP-03 §Core Event Fields requires exactly the 32-byte nostr_group_id in lowercase hex (64 chars). Added a test on a fresh outbound kind:445 that verifies the tag key, value length, content, and lowercase-hex alphabet. Verification: `./gradlew :quartz:jvmTest` passes end-to-end (0 failures). https://claude.ai/code/session_014N7vG2TPgEeh7sQTpyHjJZ --- .../quartz/marmot/MarmotMipComplianceTest.kt | 108 ++++++++++++++++++ .../quartz/marmot/MarmotMipBehaviorTest.kt | 95 +++++++++++++++ 2 files changed, 203 insertions(+) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt index cd0a1a950..f5c7be6c0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt @@ -20,17 +20,25 @@ */ package com.vitorpamplona.quartz.marmot +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip01Groups.Mip01ImageCrypto +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04ParseResult import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.parseMip04 import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -237,6 +245,106 @@ class MarmotMipComplianceTest { assertIs(tag.parseMip04()) } + // ---------------------------------------------------------------------- MIP-00 + + private fun keyPackageEvent(encoding: String): KeyPackageEvent = + KeyPackageEvent( + id = "e".repeat(64), + pubKey = "a".repeat(64), + createdAt = 1_700_000_000, + tags = + arrayOf( + arrayOf("d", "0"), + arrayOf("encoding", encoding), + arrayOf("mls_ciphersuite", "0x0001"), + arrayOf("mls_protocol_version", "1.0"), + arrayOf("i", "b".repeat(64)), + arrayOf("mls_extensions", "0xf2ee", "0x000a"), + arrayOf("mls_proposals", "0x000a"), + arrayOf("relays", "wss://relay.example/"), + ), + content = "dGVzdA==", + sig = "s".repeat(128), + ) + + @Test + fun keyPackage_rejectsHexEncodingExplicitly() { + // MIP-00 §KeyPackage Content Encoding: "base64" is required; hex was + // the pre-migration encoding and MUST be rejected. A generic + // "non-base64" test is insufficient because hex was the legacy default + // and is the one that compliant clients most likely see in the wild. + assertFalse( + KeyPackageUtils.isValid(keyPackageEvent(encoding = "hex")), + "MIP-00 requires rejecting legacy hex encoding on KeyPackage events", + ) + } + + @Test + fun keyPackage_rejectsMissingCiphersuite() { + // MIP-00 §KeyPackage tags: mls_ciphersuite is a required tag. The + // existing isValid()-coverage checks encoding and content but not + // this tag — add a direct assertion for it. + val kp = + KeyPackageEvent( + id = "e".repeat(64), + pubKey = "a".repeat(64), + createdAt = 1_700_000_000, + tags = + arrayOf( + arrayOf("d", "0"), + arrayOf("encoding", "base64"), + arrayOf("i", "b".repeat(64)), + // mls_ciphersuite intentionally omitted + ), + content = "dGVzdA==", + sig = "s".repeat(128), + ) + assertFalse(KeyPackageUtils.isValid(kp), "Missing mls_ciphersuite tag must fail MIP-00 validation") + } + + // ---------------------------------------------------------------------- MIP-03 + + /** + * Locks in that MIP-03 outer encryption uses an EMPTY AAD. Earlier + * revisions of this code bound `nostr_group_id` into the AAD, which was + * a silent wire-format divergence from the spec — our own encrypt/decrypt + * round-trip tests kept passing because both sides agreed on the (wrong) + * AAD. This test re-decrypts a `GroupEventEncryption`-produced ciphertext + * by calling ChaCha20-Poly1305 directly with an empty AAD, which MUST + * succeed per MIP-03. A future accidental reintroduction of a non-empty + * AAD would surface here immediately. + */ + @OptIn(ExperimentalEncodingApi::class) + @Test + fun mip03_aadIsEmptyByteString() { + val key = "11".repeat(32).hexToByteArray() + val plaintext = "mip03 aad check".encodeToByteArray() + + val payloadB64 = GroupEventEncryption.encrypt(plaintext, key) + val payload = Base64.decode(payloadB64) + val nonce = payload.copyOfRange(0, GroupEvent.NONCE_LENGTH) + val ciphertextWithTag = payload.copyOfRange(GroupEvent.NONCE_LENGTH, payload.size) + + // Manual AEAD decrypt with AAD = empty byte string (spec: MIP-03). + val recovered = ChaCha20Poly1305.decrypt(ciphertextWithTag, ByteArray(0), nonce, key) + assertContentEquals(plaintext, recovered) + + // Paranoid cross-check: decrypting with a non-empty AAD (e.g. the + // nostr_group_id used by the pre-fix implementation) MUST fail + // ChaCha20-Poly1305 authentication. If it succeeds, AAD has + // silently become non-empty again. + assertFailsWith( + message = "AAD must be empty per MIP-03; a non-empty AAD cannot decrypt a spec-compliant ciphertext", + ) { + ChaCha20Poly1305.decrypt( + ciphertextWithTag, + "wouldBeGroupId".encodeToByteArray(), + nonce, + key, + ) + } + } + @Test fun mip04_rejectsWrongNonceLength() { // Only 22 hex chars = 11 bytes, below the required 12. diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt index 5dc9e7ae3..9570346f6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt @@ -21,14 +21,17 @@ package com.vitorpamplona.quartz.marmot import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle 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.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.runBlocking import kotlin.test.Test @@ -295,6 +298,98 @@ class MarmotMipBehaviorTest { ) } + // ---------------------------------------------------------------------- + // MIP-02 Welcome rumor structure after NIP-59 unwrap + // ---------------------------------------------------------------------- + + /** + * Unwraps a gift-wrapped Welcome all the way down to the kind:444 rumor + * and asserts the normative MIP-02 §Inner Rumor Structure requirements: + * - kind == 444 + * - the rumor is **unsigned** (sig == "") + * - content is base64 + * - ["encoding", "base64"] tag present + * - ["e", ] tag present + * - ["relays", ...] tag present + * + * This protects against silent wire-format drift that would otherwise + * only be visible to external Marmot clients. + */ + @Test + fun welcomePipeline_innerRumorMatchesMip02Requirements() = + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, aliceId.hexToByteArray()) + val bobBundle = createStandaloneKeyPackage(bobId) + val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes()) + + val aliceSigner = NostrSignerInternal(KeyPair()) + val bobKeyPair = KeyPair() + val bobSigner = NostrSignerInternal(bobKeyPair) + val bobPubKeyHex = bobKeyPair.pubKey.toHexKey() + + val keyPackageEventId = "c".repeat(64) + val sender = MarmotWelcomeSender(aliceSigner) + val delivery = + sender.wrapWelcome( + commitResult = commitResult, + recipientPubKey = bobPubKeyHex, + keyPackageEventId = keyPackageEventId, + relays = emptyList(), + nostrGroupId = groupId, + ) + assertNotNull(delivery) + assertEquals(GiftWrapEvent.KIND, delivery.giftWrapEvent.kind) + + // Bob unwraps: kind:1059 → kind:13 (Seal) → kind:444 (Rumor). + val sealed = delivery.giftWrapEvent.unwrapThrowing(bobSigner) + assertEquals(SealedRumorEvent.KIND, sealed.kind, "Middle layer MUST be NIP-59 Seal kind:13") + assertIs(sealed) + + val rumor = sealed.unsealThrowing(bobSigner) + assertEquals(WelcomeEvent.KIND, rumor.kind, "Innermost rumor MUST be kind:444") + assertEquals("", rumor.sig, "MIP-02: kind:444 rumor MUST NOT carry a signature") + + val encodingTag = rumor.tags.find { it.isNotEmpty() && it[0] == "encoding" } + assertNotNull(encodingTag, "MIP-02: rumor MUST carry [encoding, base64]") + assertEquals("base64", encodingTag[1]) + + val eTag = rumor.tags.find { it.isNotEmpty() && it[0] == "e" } + assertNotNull(eTag, "MIP-02: rumor MUST carry [e, ]") + assertEquals(keyPackageEventId, eTag[1]) + + val relaysTag = rumor.tags.find { it.isNotEmpty() && it[0] == "relays" } + assertNotNull(relaysTag, "MIP-02: rumor MUST carry a relays tag") + } + + // ---------------------------------------------------------------------- + // MIP-03 group event h-tag shape + // ---------------------------------------------------------------------- + + /** + * MIP-03 §Core Event Fields requires the `h` tag on kind:445 events to + * be a 32-byte hex (64 lowercase hex chars). This locks in the tag + * format so an accidental truncation or encoding change surfaces here. + */ + @Test + fun buildGroupEvent_hTagIs32ByteHex() = + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, aliceId.hexToByteArray()) + + val outbound = MarmotOutboundProcessor(manager) + val result = outbound.buildGroupEventFromBytes(groupId, "hi".encodeToByteArray()) + + val hTag = result.signedEvent.tags.find { it.isNotEmpty() && it[0] == "h" } + assertNotNull(hTag, "kind:445 MUST carry an h tag with the Nostr group id") + assertEquals(64, hTag[1].length, "h tag value MUST be 32 bytes hex (64 chars)") + assertEquals(groupId, hTag[1]) + assertTrue( + hTag[1].all { it in '0'..'9' || it in 'a'..'f' }, + "h tag MUST be lowercase hex", + ) + } + @Test fun processGroupEvent_acceptsInnerEventWithMatchingPubkey() = runBlocking {