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
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<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
fun mip04_rejectsWrongNonceLength() {
// Only 22 hex chars = 11 bytes, below the required 12.
@@ -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", <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
fun processGroupEvent_acceptsInnerEventWithMatchingPubkey() =
runBlocking<Unit> {