test(marmot): close MIP assertion gaps surfaced by spec review

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",<KeyPackage event id>]
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
This commit is contained in:
Claude
2026-04-17 22:53:52 +00:00
parent 6e326e0fa7
commit 03c5aee9da
2 changed files with 203 additions and 0 deletions
@@ -20,17 +20,25 @@
*/ */
package com.vitorpamplona.quartz.marmot 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.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mip01Groups.Mip01ImageCrypto 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.Mip04MediaEncryption
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04ParseResult import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04ParseResult
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.parseMip04 import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.parseMip04
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertContentEquals import kotlin.test.assertContentEquals
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
@@ -237,6 +245,106 @@ class MarmotMipComplianceTest {
assertIs<Mip04ParseResult.Invalid>(tag.parseMip04()) assertIs<Mip04ParseResult.Invalid>(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<IllegalStateException>(
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 @Test
fun mip04_rejectsWrongNonceLength() { fun mip04_rejectsWrongNonceLength() {
// Only 22 hex chars = 11 bytes, below the required 12. // Only 22 hex chars = 11 bytes, below the required 12.
@@ -21,14 +21,17 @@
package com.vitorpamplona.quartz.marmot package com.vitorpamplona.quartz.marmot
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData 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.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray 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.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test 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", <KeyPackage event id>] 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<Unit> {
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<SealedRumorEvent>(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, <KeyPackage event id>]")
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<Unit> {
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 @Test
fun processGroupEvent_acceptsInnerEventWithMatchingPubkey() = fun processGroupEvent_acceptsInnerEventWithMatchingPubkey() =
runBlocking<Unit> { runBlocking<Unit> {