diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index d4b46997b..893ab7215 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -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 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt index 78d7287ea..df3cecc28 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt @@ -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() + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt index 3bd3d2d2f..6de406ed7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt @@ -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, 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, nostrGroupId: HexKey? = null, + awaitCommitAck: suspend () -> Unit = {}, ): WelcomeDelivery { val welcomeBase64 = Base64.encode(welcomeBytes) + awaitCommitAck() + val giftWrap = WelcomeGiftWrap.wrapForRecipient( welcomeBase64 = welcomeBase64, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt index 5adefd138..e326bbcf3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt @@ -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 = 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): 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 } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/Mip01ImageCrypto.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/Mip01ImageCrypto.kt new file mode 100644 index 000000000..9600161e7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/Mip01ImageCrypto.kt @@ -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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEventEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEventEncryption.kt index 843db6888..afbf7f2fd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEventEncryption.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEventEncryption.kt @@ -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) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt index 0028a3c3e..08b80a230 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt @@ -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. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt index ea206b8a3..d4285db2a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index 05d2bc591..31f80dde7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -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 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) { + 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) { + // 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() + .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() + 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() + 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: uint16 each + val exts = TlsWriter() + exts.putUint16(MARMOT_GROUP_DATA_EXTENSION_TYPE) + writer.putOpaqueVarInt(exts.toByteArray()) + // proposals: uint16 each + val props = TlsWriter() + props.putUint16(SELF_REMOVE_PROPOSAL_TYPE) + writer.putOpaqueVarInt(props.toByteArray()) + // credentials: 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() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt index 7baddccba..3b689b114 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt new file mode 100644 index 000000000..cd0a1a950 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt @@ -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 { + 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 { Mip01ImageCrypto.deriveImageEncryptionKey(short) } + assertFailsWith { 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(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(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(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(tag.parseMip04()) + } +}