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.
|
||||
|
||||
Reference in New Issue
Block a user