feat(marmot): close MIP-01 through MIP-04 compliance gaps

Audits against https://github.com/marmot-protocol/marmot surfaced several
deviations from the Marmot specs. This commit addresses them across three
tiers.

Wire-format / interop (Tier 1)
- MarmotGroupData: bump CURRENT_VERSION to 3 (MIP-01 v3), add
  disappearing_message_secs field, validate version is supported, reject
  value 0 for the disappearing duration.
- GroupEventEncryption: use empty AAD (ByteArray(0)) per MIP-03 instead of
  binding nostr_group_id. Callers updated. This is wire-breaking against
  the prior (non-compliant) encoder.
- Mip01ImageCrypto: new helper with HKDF derivations for the group image
  encryption key (label "mip01-image-encryption-v2") and the Blossom
  upload keypair seed (label "mip01-blossom-upload-v2").
- MarmotOutboundProcessor: auto-apply NIP-40 expiration tag on kind:445
  events when the group has disappearing_message_secs configured.

Authorization / MLS (Tier 2)
- MlsGroup helpers: memberIdentity/myIdentityHex/currentMarmotData/
  isLocalAdmin/isLeafAdmin.
- proposeSelfRemove / selfRemove: reject members listed in admin_pubkeys
  (MIP-01: admins must self-demote first).
- MlsGroup.commit(): non-admin members may only commit a single self-Update
  or SelfRemove-only proposals; admin-depletion guard rejects commits that
  would leave the group without any admin.
- MlsGroup.create(): install the RFC 9420 required_capabilities extension
  in the GroupContext and advertise marmot_group_data (0xF2EE) +
  self_remove (0x000A) in the creator's leaf capabilities.
- MlsGroupManager.updateGroupExtensions: admin gate (relaxed during
  bootstrap when no admins are yet configured).
- MlsGroupManager.memberIdentityHex: expose credential identity lookup.
- MarmotInboundProcessor: after MLS decrypt, verify the inner Nostr
  event's pubkey matches the MLS sender's BasicCredential identity.

Hardening (Tier 3)
- Mip04IMetaTag: new Mip04ParseResult sealed class with explicit
  DeprecatedV1 variant. parseMip04 logs a security warning when it
  encounters mip04-v1 instead of silently returning null.
- Mip04MediaEncryption: expose LEGACY_VERSION_V1 = "mip04-v1" constant.
- MarmotWelcomeSender: new awaitCommitAck suspend parameter on
  wrapWelcome / wrapWelcomeBytes so callers can plumb the Commit ack
  wait through the sender (MIP-02 ordering requirement).

Tests
- MarmotMipComplianceTest covers MarmotGroupData v3 round-trip (with and
  without disappearing_message_secs), constructor/decoder validation of
  disappearing=0 and unsupported versions, Mip01ImageCrypto
  determinism and label separation, and Mip04ParseResult v2/v1/invalid
  classification.

https://claude.ai/code/session_014N7vG2TPgEeh7sQTpyHjJZ
This commit is contained in:
Claude
2026-04-17 21:40:01 +00:00
parent 58e6ba6897
commit 1dc065f84a
11 changed files with 809 additions and 53 deletions
@@ -0,0 +1,256 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.marmot
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mip01Groups.Mip01ImageCrypto
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.nip92IMeta.IMetaTagBuilder
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Unit tests for the Marmot Protocol MIP compliance fixes landing alongside
* this test file. Covers:
*
* - MIP-01: `MarmotGroupData` v3 round-trip, `disappearing_message_secs`
* validation, version rejection, image/Blossom HKDF helpers.
* - MIP-04: `Mip04ParseResult.DeprecatedV1` emission and deprecation warning
* for legacy v1 imeta tags.
*/
class MarmotMipComplianceTest {
private val groupId32 = "0".repeat(64)
private val adminPubkey = "a".repeat(64)
// ---------------------------------------------------------------------- MIP-01
@Test
fun marmotGroupData_defaultVersionIsThree() {
assertEquals(3, MarmotGroupData.CURRENT_VERSION)
}
@Test
fun marmotGroupData_roundTripWithoutDisappearing() {
val original =
MarmotGroupData(
nostrGroupId = groupId32,
name = "test group",
description = "desc",
adminPubkeys = listOf(adminPubkey),
relays = listOf("wss://relay.example/"),
)
val bytes = original.encodeTls()
val decoded = assertNotNull(MarmotGroupData.decodeTls(bytes))
assertEquals(original.version, decoded.version)
assertEquals(original.nostrGroupId, decoded.nostrGroupId)
assertEquals(original.name, decoded.name)
assertEquals(original.description, decoded.description)
assertEquals(original.adminPubkeys, decoded.adminPubkeys)
assertEquals(original.relays, decoded.relays)
assertNull(decoded.disappearingMessageSecs)
}
@Test
fun marmotGroupData_roundTripWithDisappearingSecs() {
val original =
MarmotGroupData(
nostrGroupId = groupId32,
adminPubkeys = listOf(adminPubkey),
relays = listOf("wss://relay.example/"),
disappearingMessageSecs = 86_400UL,
)
val bytes = original.encodeTls()
val decoded = assertNotNull(MarmotGroupData.decodeTls(bytes))
assertEquals(86_400UL, decoded.disappearingMessageSecs)
}
@Test
fun marmotGroupData_rejectsZeroDisappearingSecsInConstructor() {
assertFailsWith<IllegalArgumentException> {
MarmotGroupData(
nostrGroupId = groupId32,
disappearingMessageSecs = 0UL,
)
}
}
@Test
fun marmotGroupData_rejectsZeroDisappearingSecsOnDecode() {
// Hand-crafted TLS blob: version=3, group_id=32x0, empty opaque2 for
// name/description/admins/relays/images, then disappearing_message_secs
// = 8 bytes of zero (invalid).
val header =
ByteArray(2 + 32) {
// version + groupId
when (it) {
0 -> 0
1 -> 3
// version=3
else -> 0
}
}
// 8x opaque2 fields of length 0, each encoded as two zero bytes:
// name, description, admin_pubkeys, relays, image_hash, image_key,
// image_nonce, image_upload_key
val zeroFields = ByteArray(8 * 2) // all zeros
// disappearing_message_secs opaque2 with 8 zero bytes
val disappearingField = ByteArray(2 + 8).also { it[1] = 8 }
val blob = header + zeroFields + disappearingField
// decodeTls catches any exception and returns null
assertNull(MarmotGroupData.decodeTls(blob))
}
@Test
fun marmotGroupData_rejectsUnsupportedVersion() {
// version=99 is not in SUPPORTED_VERSIONS
val header =
ByteArray(2 + 32).also {
it[0] = 0
it[1] = 99
}
val zeroFields = ByteArray(8 * 2) // name..image_upload_key
val disappearingField = ByteArray(2) // zero-length
val blob = header + zeroFields + disappearingField
assertNull(MarmotGroupData.decodeTls(blob))
}
@Test
fun mip01ImageCrypto_deriveImageKeyIs32BytesAndDeterministic() {
val seed = "11".repeat(32).hexToByteArray()
val k1 = Mip01ImageCrypto.deriveImageEncryptionKey(seed)
val k2 = Mip01ImageCrypto.deriveImageEncryptionKey(seed)
assertEquals(32, k1.size)
assertContentEquals(k1, k2)
}
@Test
fun mip01ImageCrypto_imageAndUploadKeysDiffer() {
val seed = "22".repeat(32).hexToByteArray()
val imageKey = Mip01ImageCrypto.deriveImageEncryptionKey(seed)
val uploadSeed = Mip01ImageCrypto.deriveBlossomUploadSeed(seed)
assertEquals(32, imageKey.size)
assertEquals(32, uploadSeed.size)
assertTrue(
!imageKey.contentEquals(uploadSeed),
"Distinct HKDF labels must produce different outputs",
)
}
@Test
fun mip01ImageCrypto_rejectsWrongSeedLength() {
val short = ByteArray(16)
assertFailsWith<IllegalArgumentException> { Mip01ImageCrypto.deriveImageEncryptionKey(short) }
assertFailsWith<IllegalArgumentException> { Mip01ImageCrypto.deriveBlossomUploadSeed(short) }
}
// ---------------------------------------------------------------------- MIP-04
@Test
fun mip04_parsesValidV2Tag() {
val nonceHex = "00".repeat(12)
val fileHashHex = "33".repeat(32)
val tag =
IMetaTagBuilder("https://blobs.example/abc")
.add("m", "image/jpeg")
.add("filename", "photo.jpg")
.add("x", fileHashHex)
.add("n", nonceHex)
.add("v", Mip04MediaEncryption.VERSION)
.build()
val parsed = tag.parseMip04()
assertIs<Mip04ParseResult.Parsed>(parsed)
assertEquals(Mip04MediaEncryption.VERSION, parsed.meta.version)
assertEquals("photo.jpg", parsed.meta.filename)
}
@Test
fun mip04_rejectsDeprecatedV1Tag() {
val nonceHex = "00".repeat(12)
val fileHashHex = "44".repeat(32)
val tag =
IMetaTagBuilder("https://blobs.example/def")
.add("m", "image/png")
.add("filename", "old.png")
.add("x", fileHashHex)
.add("n", nonceHex)
.add("v", Mip04MediaEncryption.LEGACY_VERSION_V1)
.build()
val parsed = tag.parseMip04()
assertIs<Mip04ParseResult.DeprecatedV1>(parsed)
assertEquals("https://blobs.example/def", parsed.url)
}
@Test
fun mip04_rejectsUnknownVersion() {
val nonceHex = "00".repeat(12)
val fileHashHex = "55".repeat(32)
val tag =
IMetaTagBuilder("https://blobs.example/xyz")
.add("m", "image/png")
.add("filename", "future.png")
.add("x", fileHashHex)
.add("n", nonceHex)
.add("v", "mip04-v99")
.build()
assertIs<Mip04ParseResult.Invalid>(tag.parseMip04())
}
@Test
fun mip04_rejectsWrongNonceLength() {
// Only 22 hex chars = 11 bytes, below the required 12.
val shortNonce = "00".repeat(11)
val fileHashHex = "66".repeat(32)
val tag =
IMetaTagBuilder("https://blobs.example/nonce-bad")
.add("m", "image/png")
.add("filename", "bad.png")
.add("x", fileHashHex)
.add("n", shortNonce)
.add("v", Mip04MediaEncryption.VERSION)
.build()
assertIs<Mip04ParseResult.Invalid>(tag.parseMip04())
}
}