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:
+108
@@ -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.
|
||||
|
||||
+95
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user