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
@@ -338,9 +338,30 @@ class MarmotInboundProcessor(
ContentType.APPLICATION -> {
// MLS decrypt to get the inner plaintext
val decrypted = groupManager.decrypt(groupId, mlsMessage.toTlsBytes())
val innerJson = decrypted.content.decodeToString()
// MIP-03: if the inner application payload is a Nostr event,
// its `pubkey` field MUST equal the MLS sender's credential
// identity. Reject any mismatch — otherwise a group member
// could mint events claiming a different author. Non-event
// payloads (raw bytes via buildGroupEventFromBytes) bypass
// this check since there is no author field to verify.
val innerEvent =
com.vitorpamplona.quartz.nip01Core.core.Event
.fromJsonOrNull(innerJson)
if (innerEvent != null) {
val senderIdentity = groupManager.memberIdentityHex(groupId, decrypted.senderLeafIndex)
if (senderIdentity == null || innerEvent.pubKey != senderIdentity) {
return GroupEventResult.Error(
groupId,
"MIP-03: inner event pubkey (${innerEvent.pubKey}) does not match MLS sender identity ($senderIdentity)",
)
}
}
GroupEventResult.ApplicationMessage(
groupId = groupId,
innerEventJson = decrypted.content.decodeToString(),
innerEventJson = innerJson,
senderLeafIndex = decrypted.senderLeafIndex,
epoch = decrypted.epoch,
)
@@ -461,7 +482,7 @@ class MarmotInboundProcessor(
// Try current epoch key first
try {
val exporterKey = groupManager.exporterSecret(groupId)
return GroupEventEncryption.decrypt(encryptedContent, exporterKey, groupId)
return GroupEventEncryption.decrypt(encryptedContent, exporterKey)
} catch (_: Exception) {
// Current epoch key failed — try retained epoch keys
}
@@ -470,7 +491,7 @@ class MarmotInboundProcessor(
val retainedKeys = groupManager.retainedExporterSecrets(groupId)
for (retainedKey in retainedKeys) {
try {
return GroupEventEncryption.decrypt(encryptedContent, retainedKey, groupId)
return GroupEventEncryption.decrypt(encryptedContent, retainedKey)
} catch (_: Exception) {
// This retained key didn't work — try the next one
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.marmot
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
@@ -27,6 +28,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Result of building an outbound GroupEvent.
@@ -90,14 +93,20 @@ class MarmotOutboundProcessor(
// Step 2: Outer ChaCha20-Poly1305 encryption
val exporterKey = groupManager.exporterSecret(nostrGroupId)
val encryptedContent = GroupEventEncryption.encrypt(mlsCiphertext, exporterKey, nostrGroupId)
val encryptedContent = GroupEventEncryption.encrypt(mlsCiphertext, exporterKey)
// Step 3: Build the GroupEvent template
// Step 3: Build the GroupEvent template (auto-apply NIP-40 expiration
// if the group has disappearing_message_secs configured per MIP-01/03).
val createdAt = TimeUtils.now()
val expirationTime = disappearingExpiration(nostrGroupId, createdAt)
val template =
GroupEvent.build(
encryptedContentBase64 = encryptedContent,
nostrGroupId = nostrGroupId,
)
createdAt = createdAt,
) {
if (expirationTime != null) expiration(expirationTime)
}
// Step 4: Sign with a fresh ephemeral keypair
val ephemeralSigner = NostrSignerInternal(KeyPair())
@@ -125,7 +134,7 @@ class MarmotOutboundProcessor(
): OutboundGroupEvent {
// Outer ChaCha20-Poly1305 encryption of the MLS commit
val exporterKey = groupManager.exporterSecret(nostrGroupId)
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey, nostrGroupId)
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey)
// Build the GroupEvent template
val template =
@@ -143,4 +152,22 @@ class MarmotOutboundProcessor(
nostrGroupId = nostrGroupId,
)
}
/**
* Compute the NIP-40 expiration timestamp for outbound application messages,
* or `null` when the group does not have disappearing messages configured.
*
* Per MIP-01/MIP-03, when `disappearing_message_secs` is set in the Marmot
* Group Data Extension, clients MUST auto-apply an `expiration` tag on
* kind:445 events at `created_at + disappearing_message_secs`.
*/
private fun disappearingExpiration(
nostrGroupId: HexKey,
createdAt: Long,
): Long? {
val extensions = groupManager.getGroup(nostrGroupId)?.extensions ?: return null
val marmotData = MarmotGroupData.fromExtensions(extensions) ?: return null
val secs = marmotData.disappearingMessageSecs ?: return null
return createdAt + secs.toLong()
}
}
@@ -59,10 +59,22 @@ class MarmotWelcomeSender(
/**
* Wrap Welcome bytes from a CommitResult for delivery to a new member.
*
* ## MIP-02 ordering requirement
*
* The Commit that adds [recipientPubKey] to the group MUST be confirmed by
* relays BEFORE the Welcome leaves this machine, otherwise the new member
* can join at a stale epoch and the group forks. [awaitCommitAck] is called
* immediately before the gift-wrap step so callers can plumb their own
* relay-OK wait (or equivalent guarantee) through this function. Passing
* an empty block skips the wait and is only appropriate when the caller
* has already awaited Commit confirmation externally.
*
* @param commitResult the result from MlsGroupManager.addMember()
* @param recipientPubKey public key of the new member being invited
* @param keyPackageEventId event ID of the KeyPackage that was consumed
* @param relays relays where the new member should subscribe for GroupEvents
* @param awaitCommitAck suspend block invoked before wrapping; MUST not
* return until relays have confirmed receipt of the Commit
* @return the gift-wrapped event ready for publishing, or null if no Welcome in CommitResult
*/
@OptIn(ExperimentalEncodingApi::class)
@@ -72,11 +84,14 @@ class MarmotWelcomeSender(
keyPackageEventId: HexKey,
relays: List<NormalizedRelayUrl>,
nostrGroupId: HexKey? = null,
awaitCommitAck: suspend () -> Unit = {},
): WelcomeDelivery? {
val welcomeBytes = commitResult.welcomeBytes ?: return null
val welcomeBase64 = Base64.encode(welcomeBytes)
awaitCommitAck()
val giftWrap =
WelcomeGiftWrap.wrapForRecipient(
welcomeBase64 = welcomeBase64,
@@ -98,11 +113,14 @@ class MarmotWelcomeSender(
*
* Useful when the Welcome bytes are available separately from the
* commit flow (e.g., re-sending a Welcome after a failed delivery).
* See [wrapWelcome] for the MIP-02 ordering contract on [awaitCommitAck].
*
* @param welcomeBytes raw MLS Welcome message bytes
* @param recipientPubKey public key of the new member
* @param keyPackageEventId event ID of the consumed KeyPackage
* @param relays relays for the new member to subscribe to
* @param awaitCommitAck suspend block invoked before wrapping; MUST not
* return until relays have confirmed receipt of the Commit
* @return the gift-wrapped event ready for publishing
*/
@OptIn(ExperimentalEncodingApi::class)
@@ -112,9 +130,12 @@ class MarmotWelcomeSender(
keyPackageEventId: HexKey,
relays: List<NormalizedRelayUrl>,
nostrGroupId: HexKey? = null,
awaitCommitAck: suspend () -> Unit = {},
): WelcomeDelivery {
val welcomeBase64 = Base64.encode(welcomeBytes)
awaitCommitAck()
val giftWrap =
WelcomeGiftWrap.wrapForRecipient(
welcomeBase64 = welcomeBase64,
@@ -38,25 +38,28 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
* The actual TLS serialization/deserialization is handled by the MLS engine.
* This class provides a Kotlin-friendly representation for the application layer.
*
* Wire format (TLS presentation language):
* Wire format (TLS presentation language, v3):
* ```
* struct {
* uint16 version; // Current: 2
* opaque nostr_group_id[32]; // Nostr routing ID (distinct from MLS group ID)
* uint16 version; // Current: 3 (v0 reserved/invalid)
* opaque nostr_group_id[32]; // Nostr routing ID (distinct from MLS group ID)
* opaque name<0..2^16-1>;
* opaque description<0..2^16-1>;
* opaque admin_pubkeys<0..2^16-1>; // Concatenated raw 32-byte x-only pubkeys
* opaque admin_pubkeys<0..2^16-1>; // Concatenated raw 32-byte x-only pubkeys
* RelayUrl relays<0..2^16-1>;
* opaque image_hash<0..32>;
* opaque image_key<0..32>; // HKDF seed for encryption key derivation
* opaque image_key<0..32>; // HKDF seed for encryption key derivation
* opaque image_nonce<0..12>;
* opaque image_upload_key<0..32>; // HKDF seed for upload keypair derivation
* opaque image_upload_key<0..32>; // HKDF seed for upload keypair derivation
* opaque disappearing_message_secs<0..8>; // v3+: 0 bytes = persist forever,
* // 8 bytes big-endian uint64 = expiration secs
* // (value 0 is rejected)
* } NostrGroupData;
* ```
*/
@Immutable
data class MarmotGroupData(
/** Extension format version. Current: 2 */
/** Extension format version. Current: 3 (v0 reserved/invalid). */
val version: Int = CURRENT_VERSION,
/**
* 32-byte Nostr routing ID (hex-encoded).
@@ -84,7 +87,21 @@ data class MarmotGroupData(
val imageNonce: ByteArray? = null,
/** HKDF seed for deriving the Blossom upload keypair. Empty if no image. */
val imageUploadKey: ByteArray? = null,
/**
* Disappearing-message duration in seconds (v3+).
* `null` means messages persist forever. A positive value auto-applies a
* NIP-40 `expiration` tag to kind:445 events at `created_at + secs`.
* Per MIP-01, a value of `0` MUST be rejected.
*/
val disappearingMessageSecs: ULong? = null,
) {
init {
require(version > 0) { "MarmotGroupData version 0 is reserved/invalid" }
require(disappearingMessageSecs == null || disappearingMessageSecs > 0UL) {
"disappearing_message_secs must be > 0 when set (MIP-01)"
}
}
/** Whether the given pubkey is an admin of this group */
fun isAdmin(pubKey: HexKey): Boolean = adminPubkeys.contains(pubKey)
@@ -122,6 +139,19 @@ data class MarmotGroupData(
writer.putOpaque2(imageNonce ?: ByteArray(0))
writer.putOpaque2(imageUploadKey ?: ByteArray(0))
// v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs)
val disappearingBytes =
disappearingMessageSecs?.let { secs ->
val out = ByteArray(8)
var v = secs.toLong()
for (i in 7 downTo 0) {
out[i] = (v and 0xFF).toByte()
v = v ushr 8
}
out
} ?: ByteArray(0)
writer.putOpaque2(disappearingBytes)
return writer.toByteArray()
}
@@ -131,7 +161,10 @@ data class MarmotGroupData(
fun toExtension(): Extension = Extension(EXTENSION_ID_INT, encodeTls())
companion object {
const val CURRENT_VERSION = 2
const val CURRENT_VERSION = 3
/** Versions this implementation understands. v0 is reserved/invalid per MIP-01. */
val SUPPORTED_VERSIONS: Set<Int> = setOf(1, 2, 3)
/** MLS extension type identifier for marmot_group_data */
const val EXTENSION_ID: UShort = 0xF2EEu
@@ -139,7 +172,7 @@ data class MarmotGroupData(
/**
* Find and decode the MarmotGroupData extension from a list of MLS extensions.
* Returns null if no extension with type 0xF2EE is present.
* Returns null if no extension with type 0xF2EE is present or if decoding fails.
*/
fun fromExtensions(extensions: List<Extension>): MarmotGroupData? {
val ext = extensions.find { it.extensionType == EXTENSION_ID_INT } ?: return null
@@ -149,24 +182,29 @@ data class MarmotGroupData(
/**
* Decode MarmotGroupData from TLS wire format bytes.
*
* Wire format:
* Wire format (v3):
* ```
* uint16 version
* uint16 version // rejected if 0 or unsupported
* opaque nostr_group_id[32]
* opaque name<0..2^16-1>
* opaque description<0..2^16-1>
* opaque admin_pubkeys<0..2^16-1> // concatenated 32-byte keys
* RelayUrl relays<0..2^16-1> // length-prefixed UTF-8 strings
* opaque admin_pubkeys<0..2^16-1> // concatenated 32-byte keys
* RelayUrl relays<0..2^16-1> // length-prefixed UTF-8 strings
* opaque image_hash<0..32>
* opaque image_key<0..32>
* opaque image_nonce<0..12>
* opaque image_upload_key<0..32>
* opaque disappearing_message_secs<0..8> // v3+: 0 bytes or 8-byte uint64 (reject 0)
* ```
*
* Unknown trailing bytes from future versions are silently ignored for
* forward compatibility (MIP-01).
*/
fun decodeTls(data: ByteArray): MarmotGroupData? =
try {
val reader = TlsReader(data)
val version = reader.readUint16()
require(version in SUPPORTED_VERSIONS) { "Unsupported MarmotGroupData version: $version" }
val nostrGroupIdBytes = reader.readBytes(32)
val nostrGroupId = nostrGroupIdBytes.toHexKey()
@@ -201,6 +239,30 @@ data class MarmotGroupData(
val imageNonce = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
val imageUploadKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
// v3+: disappearing_message_secs
val disappearingBytes = if (reader.hasRemaining) reader.readOpaque2() else ByteArray(0)
val disappearingMessageSecs: ULong? =
when (disappearingBytes.size) {
0 -> {
null
}
8 -> {
var v = 0UL
for (b in disappearingBytes) {
v = (v shl 8) or (b.toInt() and 0xFF).toULong()
}
require(v > 0UL) { "disappearing_message_secs value 0 is rejected (MIP-01)" }
v
}
else -> {
throw IllegalArgumentException(
"disappearing_message_secs must be 0 or 8 bytes, got ${disappearingBytes.size}",
)
}
}
MarmotGroupData(
version = version,
nostrGroupId = nostrGroupId,
@@ -212,8 +274,9 @@ data class MarmotGroupData(
imageKey = imageKey,
imageNonce = imageNonce,
imageUploadKey = imageUploadKey,
disappearingMessageSecs = disappearingMessageSecs,
)
} catch (e: Exception) {
} catch (_: Exception) {
null
}
}
@@ -0,0 +1,82 @@
/*
* 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.mip01Groups
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
/**
* MIP-01 image & Blossom upload key derivations.
*
* Per MIP-01 v2, the `image_key` and `image_upload_key` seeds stored in the
* Marmot Group Data Extension are HKDF IKMs used to derive:
*
* - The ChaCha20-Poly1305 key that decrypts the encrypted image blob in Blossom.
* - The 32-byte seed used to deterministically derive the Blossom upload keypair
* (so admins can delete old images when updating group metadata).
*
* Both derivations use HKDF-SHA256 as: `HKDF-Extract(salt=∅, IKM=seed)` followed
* by `HKDF-Expand(prk, info, 32)` where `info` is the MIP-01 versioned label.
*/
object Mip01ImageCrypto {
/** HKDF-Expand info label for the group image encryption key (MIP-01 v2). */
const val IMAGE_ENCRYPTION_LABEL = "mip01-image-encryption-v2"
/** HKDF-Expand info label for the Blossom upload keypair seed (MIP-01 v2). */
const val BLOSSOM_UPLOAD_LABEL = "mip01-blossom-upload-v2"
/** Required length of every derived output (bytes). */
const val OUTPUT_LENGTH = 32
private val EMPTY_SALT = ByteArray(0)
/**
* Derive the ChaCha20-Poly1305 key used to decrypt/encrypt the group image blob.
*
* @param imageKey 32-byte HKDF seed from `MarmotGroupData.imageKey`.
* @return 32-byte symmetric key.
*/
fun deriveImageEncryptionKey(imageKey: ByteArray): ByteArray {
require(imageKey.size == OUTPUT_LENGTH) {
"image_key must be $OUTPUT_LENGTH bytes, got ${imageKey.size}"
}
val prk = MlsCryptoProvider.hkdfExtract(EMPTY_SALT, imageKey)
return MlsCryptoProvider.hkdfExpand(prk, IMAGE_ENCRYPTION_LABEL.encodeToByteArray(), OUTPUT_LENGTH)
}
/**
* Derive the deterministic seed for the Blossom upload keypair.
*
* The returned 32-byte value is the input to whatever keypair generation
* scheme the caller uses for Blossom (typically a Nostr-style secp256k1
* private key). Because the seed is deterministic, any admin that holds
* `image_upload_key` can recreate the keypair later to delete old blobs.
*
* @param imageUploadKey 32-byte HKDF seed from `MarmotGroupData.imageUploadKey`.
* @return 32-byte private-key seed.
*/
fun deriveBlossomUploadSeed(imageUploadKey: ByteArray): ByteArray {
require(imageUploadKey.size == OUTPUT_LENGTH) {
"image_upload_key must be $OUTPUT_LENGTH bytes, got ${imageUploadKey.size}"
}
val prk = MlsCryptoProvider.hkdfExtract(EMPTY_SALT, imageUploadKey)
return MlsCryptoProvider.hkdfExpand(prk, BLOSSOM_UPLOAD_LABEL.encodeToByteArray(), OUTPUT_LENGTH)
}
}
@@ -29,34 +29,37 @@ import kotlin.io.encoding.ExperimentalEncodingApi
* Handles the outer ChaCha20-Poly1305 encryption layer for Marmot GroupEvents (MIP-03).
*
* The encryption flow:
* Encrypt: content = base64(randomNonce(12) || ChaCha20-Poly1305.encrypt(key, nonce, mlsMessageBytes, aad=""))
* Encrypt: content = base64(randomNonce(12) || ChaCha20-Poly1305.encrypt(key, nonce, mlsMessageBytes, aad=empty))
* Decrypt: decode base64, split nonce (first 12 bytes) from ciphertext+tag, decrypt with empty AAD
*
* Per MIP-03, the AAD is the empty byte string. Earlier versions of this
* implementation bound `nostr_group_id` into the AAD; that was corrected to
* match the spec, which necessarily breaks decryption of messages produced
* by the old (non-compliant) encoder.
*
* The key is derived from MLS-Exporter("marmot", "group-event", 32) by the MLS engine.
* Since the MLS engine is not yet integrated, this helper accepts the 32-byte key as a parameter.
*/
object GroupEventEncryption {
private val EMPTY_AAD = ByteArray(0)
/**
* Encrypts an MLS message for a GroupEvent.
*
* @param mlsMessageBytes the raw MLS message bytes to encrypt
* @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32)
* @param nostrGroupId hex-encoded group ID bound to the ciphertext via AAD
* @return base64-encoded string containing nonce(12) || ciphertext || tag(16)
*/
@OptIn(ExperimentalEncodingApi::class)
fun encrypt(
mlsMessageBytes: ByteArray,
groupKey: ByteArray,
nostrGroupId: String = "",
): String {
require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) {
"Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes"
}
val aad = nostrGroupId.encodeToByteArray()
val nonce = RandomInstance.bytes(GroupEvent.NONCE_LENGTH)
val ciphertextWithTag = ChaCha20Poly1305.encrypt(mlsMessageBytes, aad, nonce, groupKey)
val ciphertextWithTag = ChaCha20Poly1305.encrypt(mlsMessageBytes, EMPTY_AAD, nonce, groupKey)
// Prepend nonce to ciphertext+tag
val payload = ByteArray(nonce.size + ciphertextWithTag.size)
@@ -71,7 +74,6 @@ object GroupEventEncryption {
*
* @param encryptedContentBase64 base64-encoded content from the GroupEvent
* @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32)
* @param nostrGroupId hex-encoded group ID bound to the ciphertext via AAD
* @return decrypted MLS message bytes
* @throws IllegalStateException if authentication fails
* @throws IllegalArgumentException if content is malformed
@@ -80,7 +82,6 @@ object GroupEventEncryption {
fun decrypt(
encryptedContentBase64: String,
groupKey: ByteArray,
nostrGroupId: String = "",
): ByteArray {
require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) {
"Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes"
@@ -91,10 +92,9 @@ object GroupEventEncryption {
"Payload too short: ${payload.size} bytes, minimum ${GroupEvent.MIN_CONTENT_LENGTH}"
}
val aad = nostrGroupId.encodeToByteArray()
val nonce = payload.copyOfRange(0, GroupEvent.NONCE_LENGTH)
val ciphertextWithTag = payload.copyOfRange(GroupEvent.NONCE_LENGTH, payload.size)
return ChaCha20Poly1305.decrypt(ciphertextWithTag, aad, nonce, groupKey)
return ChaCha20Poly1305.decrypt(ciphertextWithTag, EMPTY_AAD, nonce, groupKey)
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.utils.Log
/**
* MIP-04 imeta tag field names per the spec.
@@ -61,32 +62,99 @@ data class Mip04MediaMeta(
}
/**
* Parse an IMetaTag into MIP-04 media metadata.
* Returns null if the tag is not a valid MIP-04 v2 imeta.
* Structured result of parsing a MIP-04 imeta tag.
*
* Lets callers distinguish "this isn't a MIP-04 tag" (skip), "looks like a
* deprecated v1 blob with a nonce-reuse vulnerability" (show warning / block
* decrypt), from valid v2 metadata.
*/
fun IMetaTag.toMip04MediaMeta(): Mip04MediaMeta? {
val mimeType = properties[Mip04Fields.MIME_TYPE]?.firstOrNull() ?: return null
val filename = properties[Mip04Fields.FILENAME]?.firstOrNull() ?: return null
val fileHash = properties[Mip04Fields.FILE_HASH]?.firstOrNull() ?: return null
val nonce = properties[Mip04Fields.NONCE]?.firstOrNull() ?: return null
val version = properties[Mip04Fields.VERSION]?.firstOrNull() ?: return null
sealed class Mip04ParseResult {
/** The tag is a well-formed MIP-04 v2 media descriptor. */
data class Parsed(
val meta: Mip04MediaMeta,
) : Mip04ParseResult()
if (version != Mip04MediaEncryption.VERSION) return null
if (nonce.length != 24) return null // 12 bytes = 24 hex chars
/**
* The tag advertises `v=mip04-v1`. Per MIP-04 §"Deprecated Version 1",
* clients MUST reject these blobs because v1 derived the ChaCha20
* nonce deterministically and is vulnerable to nonce-reuse attacks.
*/
data class DeprecatedV1(
val url: String,
) : Mip04ParseResult()
return Mip04MediaMeta(
url = url,
mimeType = mimeType,
filename = filename,
originalFileHash = fileHash,
nonce = nonce,
version = version,
dimensions = properties[Mip04Fields.DIMENSIONS]?.firstOrNull(),
blurhash = properties[Mip04Fields.BLURHASH]?.firstOrNull(),
thumbhash = properties[Mip04Fields.THUMBHASH]?.firstOrNull(),
/** Missing required MIP-04 fields; treat as a non-MIP-04 imeta tag. */
data object NotMip04 : Mip04ParseResult()
/** Malformed MIP-04 tag (e.g. wrong nonce length or unknown version). */
data class Invalid(
val reason: String,
) : Mip04ParseResult()
}
private const val MIP04_LOG_TAG = "Mip04"
/**
* Parse an IMetaTag into a structured [Mip04ParseResult]. The result captures
* the deprecated-v1 case separately so callers can surface a warning instead
* of silently dropping the media.
*/
fun IMetaTag.parseMip04(): Mip04ParseResult {
val mimeType = properties[Mip04Fields.MIME_TYPE]?.firstOrNull()
val filename = properties[Mip04Fields.FILENAME]?.firstOrNull()
val fileHash = properties[Mip04Fields.FILE_HASH]?.firstOrNull()
val nonce = properties[Mip04Fields.NONCE]?.firstOrNull()
val version = properties[Mip04Fields.VERSION]?.firstOrNull()
if (mimeType == null || filename == null || fileHash == null || nonce == null || version == null) {
return Mip04ParseResult.NotMip04
}
if (version == Mip04MediaEncryption.LEGACY_VERSION_V1) {
Log.w(MIP04_LOG_TAG) {
"Rejecting MIP-04 v1 imeta for $url: v1 used deterministic nonces and is " +
"vulnerable to nonce-reuse attacks. Re-upload with mip04-v2."
}
return Mip04ParseResult.DeprecatedV1(url)
}
if (version != Mip04MediaEncryption.VERSION) {
return Mip04ParseResult.Invalid("Unknown MIP-04 version: $version")
}
if (nonce.length != 24) {
return Mip04ParseResult.Invalid("nonce must be 24 hex chars (12 bytes), got ${nonce.length}")
}
return Mip04ParseResult.Parsed(
Mip04MediaMeta(
url = url,
mimeType = mimeType,
filename = filename,
originalFileHash = fileHash,
nonce = nonce,
version = version,
dimensions = properties[Mip04Fields.DIMENSIONS]?.firstOrNull(),
blurhash = properties[Mip04Fields.BLURHASH]?.firstOrNull(),
thumbhash = properties[Mip04Fields.THUMBHASH]?.firstOrNull(),
),
)
}
/**
* Parse an IMetaTag into MIP-04 media metadata.
*
* Returns null if the tag is not a valid MIP-04 v2 imeta. When a deprecated
* `mip04-v1` tag is encountered, a security warning is logged before null is
* returned — callers that need to distinguish that case should use
* [parseMip04] instead.
*/
fun IMetaTag.toMip04MediaMeta(): Mip04MediaMeta? =
when (val result = parseMip04()) {
is Mip04ParseResult.Parsed -> result.meta
is Mip04ParseResult.DeprecatedV1, Mip04ParseResult.NotMip04, is Mip04ParseResult.Invalid -> null
}
/**
* Build an MIP-04 imeta tag from encryption results and file metadata.
*/
@@ -44,6 +44,15 @@ import com.vitorpamplona.quartz.utils.sha256.sha256
*/
object Mip04MediaEncryption {
const val VERSION = "mip04-v2"
/**
* Deprecated v1 version string.
*
* Kept for detection only — MIP-04 requires clients to REJECT blobs tagged
* with this version. v1 derived the ChaCha20 nonce deterministically and
* is vulnerable to nonce-reuse attacks.
*/
const val LEGACY_VERSION_V1 = "mip04-v1"
const val EXPORTER_LABEL = "marmot"
const val EXPORTER_CONTEXT = "encrypted-media"
const val EXPORTER_KEY_LENGTH = 32
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.marmot.mls.group
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
@@ -114,6 +115,32 @@ class MlsGroup private constructor(
val leafIndex: Int get() = myLeafIndex
val extensions: List<com.vitorpamplona.quartz.marmot.mls.tree.Extension> get() = groupContext.extensions
// --- Marmot admin helpers (MIP-01 / MIP-03) ---
/** Raw BasicCredential identity bytes of the member at the given leaf, or null. */
fun memberIdentity(leafIndex: Int): ByteArray? = (tree.getLeaf(leafIndex)?.credential as? Credential.Basic)?.identity
/** Lowercase hex of the member's BasicCredential identity, or null. */
fun memberIdentityHex(leafIndex: Int): String? = memberIdentity(leafIndex)?.toHexKey()
/** Lowercase hex of the local member's BasicCredential identity, or null. */
fun myIdentityHex(): String? = memberIdentityHex(myLeafIndex)
/** Parsed Marmot Group Data Extension from the current GroupContext, or null. */
fun currentMarmotData(): MarmotGroupData? = MarmotGroupData.fromExtensions(groupContext.extensions)
/** True if the local member appears in the group's current `admin_pubkeys` list. */
fun isLocalAdmin(): Boolean {
val id = myIdentityHex() ?: return false
return currentMarmotData()?.isAdmin(id) ?: false
}
/** True if the member at [leafIndex] is listed as admin in the current group data. */
fun isLeafAdmin(leafIndex: Int): Boolean {
val id = memberIdentityHex(leafIndex) ?: return false
return currentMarmotData()?.isAdmin(id) ?: false
}
// --- State Persistence ---
/**
@@ -255,8 +282,16 @@ class MlsGroup private constructor(
/**
* Create a SelfRemove proposal.
*
* Per MIP-01/MIP-03, members listed in `admin_pubkeys` MUST NOT issue a
* SelfRemove — they have to first publish a GroupContextExtensions proposal
* removing themselves from the admin list (self-demotion). This guard
* enforces that rule at the local sender.
*/
fun proposeSelfRemove(): Proposal.SelfRemove {
check(!isLocalAdmin()) {
"Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)"
}
val proposal = Proposal.SelfRemove()
pendingProposals.add(PendingProposal(proposal, myLeafIndex))
return proposal
@@ -351,6 +386,20 @@ class MlsGroup private constructor(
*/
fun commit(): CommitResult {
val proposals = pendingProposals.toList()
// --- MIP-03 authorization gate -----------------------------------------
//
// Non-admin senders may only issue one of two restricted commit shapes:
// (a) a single self-Update targeting their own leaf, or
// (b) one or more SelfRemove proposals, all by themselves (no mixing).
//
// Admins may commit any proposal type.
enforceAuthorizedProposalSet(proposals)
// Reject commits that would leave the group without a usable admin
// (i.e. no remaining member appears in the post-commit admin list).
enforceNoAdminDepletion(proposals)
val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) }
// Check if we need an UpdatePath (required unless only SelfRemove)
@@ -1161,6 +1210,87 @@ class MlsGroup private constructor(
return tree.addLeaf(leafNode)
}
/**
* MIP-03 authorization gate for local commits.
*
* Once the group has at least one admin configured in `admin_pubkeys`,
* non-admin senders may only issue:
* - a single self-Update proposal, or
* - one-or-more SelfRemove proposals authored by this member
*
* Admins may commit any proposal type. Before any admin is configured
* (group bootstrap) the check is relaxed, mirroring the bootstrap policy
* in [MlsGroupManager.updateGroupExtensions].
*/
private fun enforceAuthorizedProposalSet(proposals: List<PendingProposal>) {
if (proposals.isEmpty()) return
val marmot = currentMarmotData()
val adminsConfigured = marmot != null && marmot.adminPubkeys.isNotEmpty()
if (!adminsConfigured || isLocalAdmin()) return
val allSelfRemove =
proposals.all { it.proposal is Proposal.SelfRemove && it.senderLeafIndex == myLeafIndex }
if (allSelfRemove) return
val singleSelfUpdate =
proposals.size == 1 &&
proposals[0].proposal is Proposal.Update &&
proposals[0].senderLeafIndex == myLeafIndex
if (singleSelfUpdate) return
throw IllegalStateException(
"MIP-03: non-admin members may only commit a single self-Update or SelfRemove-only " +
"proposals; got ${proposals.map { it.proposal::class.simpleName }}",
)
}
/**
* Reject any commit that would leave the group without at least one member
* still listed in `admin_pubkeys` (MIP-03 admin depletion guard).
*
* We simulate the post-commit member set and the post-commit `admin_pubkeys`
* list, then require a non-empty intersection.
*/
private fun enforceNoAdminDepletion(proposals: List<PendingProposal>) {
// Resolve the effective admin list after any GroupContextExtensions
// proposal in this commit. If none is present, keep the current list.
val gce =
proposals
.asSequence()
.map { it.proposal }
.filterIsInstance<Proposal.GroupContextExtensions>()
.lastOrNull()
val projectedMarmot =
if (gce != null) {
MarmotGroupData.fromExtensions(gce.extensions)
} else {
currentMarmotData()
}
val adminSet = projectedMarmot?.adminPubkeys?.toSet().orEmpty()
if (adminSet.isEmpty()) return // No admins configured — nothing to protect.
// Compute which leaves remain after applying Removes/SelfRemoves.
val removedLeaves = mutableSetOf<Int>()
for (pending in proposals) {
when (val p = pending.proposal) {
is Proposal.Remove -> removedLeaves.add(p.removedLeafIndex)
is Proposal.SelfRemove -> removedLeaves.add(pending.senderLeafIndex)
else -> Unit
}
}
val remainingAdminIdentities = mutableSetOf<String>()
for (i in 0 until tree.leafCount) {
if (i in removedLeaves) continue
val id = memberIdentityHex(i) ?: continue
if (id in adminSet) remainingAdminIdentities.add(id)
}
check(remainingAdminIdentities.isNotEmpty()) {
"MIP-03: commit would leave the group without any admin members"
}
}
private fun applyProposal(
proposal: Proposal,
senderLeafIndex: Int,
@@ -1357,6 +1487,12 @@ class MlsGroup private constructor(
private const val EXTERNAL_PUB_EXTENSION_TYPE = 0x0003
private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0004
/** MLS self_remove proposal type (MIP-00 / MIP-03). */
private const val SELF_REMOVE_PROPOSAL_TYPE = 0x000A
/** Marmot Group Data Extension type (MIP-01). */
private const val MARMOT_GROUP_DATA_EXTENSION_TYPE = 0xF2EE
/** Known extension types that this implementation accepts. */
private val KNOWN_EXTENSION_TYPES =
setOf(
@@ -1364,7 +1500,45 @@ class MlsGroup private constructor(
REQUIRED_CAPABILITIES_EXTENSION_TYPE,
EXTERNAL_PUB_EXTENSION_TYPE,
EXTERNAL_SENDERS_EXTENSION_TYPE,
0xF2EE, // Marmot group data extension
MARMOT_GROUP_DATA_EXTENSION_TYPE,
)
/**
* Build an MLS `required_capabilities` extension that marks Marmot's
* mandatory interop set as required for all members (RFC 9420 §7.2):
* extensions = [marmot_group_data (0xF2EE)]
* proposals = [self_remove (0x000A)]
* credentials = [Basic (0x0001)]
*/
private fun buildMarmotRequiredCapabilitiesExtension(): Extension {
val writer = TlsWriter()
// extensions<V>: uint16 each
val exts = TlsWriter()
exts.putUint16(MARMOT_GROUP_DATA_EXTENSION_TYPE)
writer.putOpaqueVarInt(exts.toByteArray())
// proposals<V>: uint16 each
val props = TlsWriter()
props.putUint16(SELF_REMOVE_PROPOSAL_TYPE)
writer.putOpaqueVarInt(props.toByteArray())
// credentials<V>: uint16 each
val creds = TlsWriter()
creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC)
writer.putOpaqueVarInt(creds.toByteArray())
return Extension(
extensionType = REQUIRED_CAPABILITIES_EXTENSION_TYPE,
extensionData = writer.toByteArray(),
)
}
/**
* Default MLS leaf Capabilities that advertise support for Marmot's
* required extensions and proposals so new members can join a group
* whose `required_capabilities` lists them.
*/
private fun marmotLeafCapabilities(): Capabilities =
Capabilities(
extensions = listOf(MARMOT_GROUP_DATA_EXTENSION_TYPE),
proposals = listOf(SELF_REMOVE_PROPOSAL_TYPE),
)
/**
@@ -1403,6 +1577,7 @@ class MlsGroup private constructor(
epoch = 0,
treeHash = treeHash,
confirmedTranscriptHash = ByteArray(0),
extensions = listOf(buildMarmotRequiredCapabilitiesExtension()),
)
// Initial key schedule with zero secrets
@@ -1794,7 +1969,9 @@ class MlsGroup private constructor(
encryptionKey = encryptionKey,
signatureKey = signatureKey,
credential = Credential.Basic(identity),
capabilities = Capabilities(),
// Advertise MIP-01/MIP-03 required capabilities so we can be
// added to compliant groups that mark them as required.
capabilities = marmotLeafCapabilities(),
leafNodeSource = source,
lifetime =
if (source == LeafNodeSource.KEY_PACKAGE) {
@@ -1833,8 +2010,14 @@ class MlsGroup private constructor(
/**
* Remove self from the group.
*
* Per MIP-01/MIP-03, admins must self-demote first; this helper rejects
* calls from a member currently listed in `admin_pubkeys`.
*/
fun selfRemove(): ByteArray {
check(!isLocalAdmin()) {
"Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)"
}
val proposal = Proposal.SelfRemove()
return proposal.toTlsBytes()
}
@@ -391,6 +391,13 @@ class MlsGroupManager(
/**
* Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal.
* Creates the proposal, commits it, and persists the new state.
*
* Authorization (MIP-01): extension updates require admin privileges once
* the group has at least one admin. During bootstrap — before any
* `admin_pubkeys` are configured — any member may seed the initial
* extension set (e.g. the creator installing the first
* [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData] with
* themselves as admin).
*/
suspend fun updateGroupExtensions(
nostrGroupId: HexKey,
@@ -398,6 +405,11 @@ class MlsGroupManager(
): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
val currentMarmot = group.currentMarmotData()
val adminsConfigured = currentMarmot != null && currentMarmot.adminPubkeys.isNotEmpty()
check(!adminsConfigured || group.isLocalAdmin()) {
"MIP-01: only admins may update group extensions"
}
retainEpochSecrets(nostrGroupId, group)
group.proposeGroupContextExtensions(extensions)
val result = group.commit()
@@ -435,6 +447,20 @@ class MlsGroupManager(
// --- Key Export ---
/**
* Hex-encoded BasicCredential identity of the member at [leafIndex] in
* the given group, or null if the group is unknown or the leaf is blank /
* not a Basic credential.
*
* Used by MIP-03 inner-event sender verification to confirm that the
* `pubkey` in a decrypted application message matches the MLS sender's
* credential identity.
*/
fun memberIdentityHex(
nostrGroupId: HexKey,
leafIndex: Int,
): String? = groups[nostrGroupId]?.memberIdentityHex(leafIndex)
/**
* Export the Marmot outer encryption key for GroupEvent wrapping.
* MLS-Exporter("marmot", "group-event", 32)
@@ -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())
}
}