From de2e71dad03edc6192cebee8d6a16797984a85f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:32:32 +0000 Subject: [PATCH] fix(marmot): match mdk/whitenoise MIP-01 v2 group-image scheme for interop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the mdk-core revision whitenoise-rs pins (marmot-protocol/mdk @e8cd584): its NostrGroupDataExtension parser consumes name/description/admins/ relays/image_hash/image_key/image_nonce/image_upload_key and rejects ANY trailing bytes at a known version, and its extension/group_image.rs fully implements avatar encryption. The previous "canonical raw-key + media_type" approach both (a) added a trailing media_type field that mdk rejects — breaking the whole group for whitenoise members — and (b) used a key scheme mdk can't decrypt. Re-implement to mdk's exact MIP-01 v2 scheme so avatars interoperate byte-for-byte: - image_key / image_upload_key are HKDF seeds (reusing Mip01ImageCrypto's mip01-image-encryption-v2 / mip01-blossom-upload-v2 labels; HKDF-SHA256 with empty salt == mdk's Hkdf::new(None, seed)). AEAD key derived from the seed. - ChaCha20-Poly1305, 12-byte nonce, EMPTY AAD, image_hash = SHA-256(ciphertext). - Decrypt tries v2 (HKDF) then falls back to v1 (raw key), exactly like mdk. - Remove media_type from the wire entirely (and from the model/cipher/uploader), so a v2 image extension ends at image_upload_key with zero trailing bytes. The plaintext MIME isn't stored; the display path lets Coil sniff the format. - Derive the Blossom upload keypair from image_upload_key instead of storing a raw key. Adds a regression test that reproduces mdk's v1/v2 field consumption and asserts a v2 image extension has no trailing bytes, plus a test pinning the HKDF-seed + empty-AAD scheme so future drift from mdk is caught. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp --- .../ui/screen/loggedIn/AccountViewModel.kt | 1 - .../marmotGroup/MarmotGroupIconDisplay.kt | 5 +- .../send/MarmotGroupIconUploader.kt | 35 ++-- .../amethyst/commons/marmot/MarmotManager.kt | 1 - .../model/marmotGroups/MarmotGroupImage.kt | 13 +- .../marmot/mip01Groups/MarmotGroupData.kt | 59 ++----- .../mip01Groups/MarmotGroupImageCipher.kt | 29 ++-- .../mip01Groups/MarmotGroupImageEncryption.kt | 158 +++++++----------- .../quartz/marmot/MarmotGroupImageTest.kt | 155 +++++++++-------- 9 files changed, 202 insertions(+), 254 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index edab313fee..9d3fb3305e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2183,7 +2183,6 @@ class AccountViewModel( imageKey = icon.upload.imageKey, imageNonce = icon.upload.imageNonce, imageUploadKey = icon.upload.imageUploadKey, - imageMediaType = icon.upload.mediaType, ) } val relays = account.marmotGroupRelays(nostrGroupId) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt index fa59b898cf..27abf2ec10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt @@ -80,11 +80,12 @@ fun rememberMarmotGroupIconUrl( value = resolver.findServers(blossomUri)?.serverUrl ?: fallbackUrl } - val cipher = remember(image) { MarmotGroupImageCipher(image.key, image.nonce, image.mediaType) } + val cipher = remember(image) { MarmotGroupImageCipher(image.key, image.nonce) } // Register the cipher only when the URL or cipher changes (not on every recomposition), // and synchronously during composition so the interceptor can decrypt before Coil fetches. + // The plaintext MIME isn't stored (MIP-01 v2), so Coil sniffs the format from the bytes. remember(url, cipher) { - Amethyst.instance.keyCache.add(url, cipher, image.mediaType) + Amethyst.instance.keyCache.add(url, cipher, null) url } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt index a1c433d09b..f207c99a99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt @@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption -import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -46,14 +45,12 @@ import java.io.File class MarmotGroupIconUpload( /** SHA-256 (hex) of the encrypted blob = its Blossom content hash. */ val imageHash: HexKey, - /** Raw 32-byte ChaCha20-Poly1305 key. */ + /** 32-byte HKDF seed for the image AEAD key (MIP-01 v2). */ val imageKey: ByteArray, /** 12-byte nonce. */ val imageNonce: ByteArray, - /** Raw 32-byte Blossom-auth secret key. */ + /** 32-byte HKDF seed for the Blossom-auth keypair (MIP-01 v2). */ val imageUploadKey: ByteArray, - /** Canonical MIME type of the plaintext image. */ - val mediaType: String, ) /** @@ -73,9 +70,10 @@ sealed class MarmotGroupIconChange { } /** - * Encrypts a picked image with the canonical `marmot-group-image-v1` scheme and - * uploads the ciphertext to Blossom, signing the upload authorization with a fresh - * keypair (so any admin holding `image_upload_key` can later replace/delete it). + * Encrypts a picked image with the MIP-01 v2 scheme (see [MarmotGroupImageEncryption]) + * and uploads the ciphertext to Blossom, signing the upload authorization with the + * keypair derived from `image_upload_key` (so any admin holding that seed can later + * replace/delete the blob). * * Reuses [UploadOrchestrator.uploadEncrypted] for compression, metadata stripping, * upload, and re-download verification — the same pipeline as MIP-04 message media. @@ -89,21 +87,21 @@ class MarmotGroupIconUploader( server: ServerName, context: Context, ): MarmotGroupIconUpload { - // Compress/downscale up front (avatars don't need full resolution) so the stored - // media_type reflects the ACTUAL post-compression bytes. The AEAD binds media_type, - // and MediaCompressor transcodes images to JPEG — so we must know the final type - // before building the cipher, then upload the already-compressed bytes uncompressed. + // Compress/downscale up front — avatars don't need full resolution, and a smaller + // blob is cheaper for every member to fetch. The MIP-01 crypto uses no AAD and does + // not bind the MIME type, so the (possibly transcoded) output type is irrelevant to + // decryption; we just hand the compressed bytes to the encrypting uploader. val compressed = MediaCompressor().compress(uri, mimeType, CompressorQuality.MEDIUM, context.applicationContext) - val mediaType = Mip04MediaEncryption.canonicalizeMimeType(compressed.contentType ?: mimeType ?: DEFAULT_MIME) - val cipher = MarmotGroupImageCipher.forNewImage(mediaType) - val uploadKey = MarmotGroupImageEncryption.generateUploadKey() - val uploadSigner = NostrSignerInternal(KeyPair(privKey = uploadKey)) + val uploadMime = compressed.contentType ?: mimeType ?: DEFAULT_MIME + val cipher = MarmotGroupImageCipher.forNewImage() + val uploadKeySeed = MarmotGroupImageEncryption.generateUploadKey() + val uploadSigner = NostrSignerInternal(KeyPair(privKey = MarmotGroupImageEncryption.deriveUploadKeypairSecret(uploadKeySeed))) try { val state = UploadOrchestrator().uploadEncrypted( uri = compressed.uri, - mimeType = mediaType, + mimeType = uploadMime, alt = null, contentWarningReason = null, compressionQuality = CompressorQuality.UNCOMPRESSED, @@ -124,8 +122,7 @@ class MarmotGroupIconUploader( imageHash = hash, imageKey = cipher.imageKey, imageNonce = cipher.imageNonce, - imageUploadKey = uploadKey, - mediaType = mediaType, + imageUploadKey = uploadKeySeed, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 3a2811f19e..66283cfa32 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -749,7 +749,6 @@ class MarmotManager( hash = metadata.imageHash!!, key = metadata.imageKey!!, nonce = metadata.imageNonce!!, - mediaType = metadata.imageMediaType, ) } else { null diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupImage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupImage.kt index d95b1eb76b..9fbf267f25 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupImage.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupImage.kt @@ -28,34 +28,31 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey * * Extracted from the group's [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData] * so front ends can render the icon: the encrypted blob is content-addressed on Blossom by - * [hash], and decrypted with [key]/[nonce] (and [mediaType] for the AEAD associated data) - * via [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption]. + * [hash], and decrypted with [key]/[nonce] via + * [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption] (MIP-01 v2: + * [key] is an HKDF seed). */ @Immutable class MarmotGroupImage( /** SHA-256 (hex) of the encrypted blob — the Blossom content hash. */ val hash: HexKey, - /** Raw ChaCha20-Poly1305 key (canonical scheme) or HKDF seed (legacy). */ + /** 32-byte HKDF seed for the image AEAD key (MIP-01 v2). */ val key: ByteArray, /** 12-byte ChaCha20-Poly1305 nonce. */ val nonce: ByteArray, - /** Canonical plaintext MIME type; null for legacy groups predating the field. */ - val mediaType: String?, ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is MarmotGroupImage) return false return hash == other.hash && key.contentEquals(other.key) && - nonce.contentEquals(other.nonce) && - mediaType == other.mediaType + nonce.contentEquals(other.nonce) } override fun hashCode(): Int { var result = hash.hashCode() result = 31 * result + key.contentHashCode() result = 31 * result + nonce.contentHashCode() - result = 31 * result + (mediaType?.hashCode() ?: 0) return result } } 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 8628540392..ae0e358e4d 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 @@ -49,21 +49,22 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey * 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>; // canonical: raw ChaCha20-Poly1305 key + * opaque image_key<0..32>; // MIP-01 v2: HKDF seed for the AEAD key * opaque image_nonce<0..12>; - * opaque image_upload_key<0..32>; // canonical: raw Blossom-auth secret key + * opaque image_upload_key<0..32>; // MIP-01 v2: HKDF seed for the Blossom-auth key * opaque disappearing_message_secs<0..8>; // v3+: 0 bytes = persist forever, * // 8 bytes big-endian uint64 = expiration secs * // (value 0 is rejected) - * opaque image_media_type<0..128>; // trailing: canonical MIME of the plaintext image * } NostrGroupData; * ``` * - * The image fields carry the canonical `marmot.group.blossom.image.v1` app-component - * data (see [MarmotGroupImageEncryption]). `image_key`/`image_upload_key` are RAW keys, - * not HKDF seeds; `image_hash` is the SHA-256 of the encrypted blob. `image_media_type` - * is appended after `disappearing_message_secs` so older readers ignore it (forward - * compatibility); it feeds the AEAD associated data on decrypt. + * The image fields carry the group avatar under the MIP-01 v2 scheme (see + * [MarmotGroupImageEncryption]). `image_key`/`image_upload_key` are HKDF **seeds**; + * `image_hash` is the SHA-256 of the encrypted blob. This is the exact field layout + * mdk-core's v1/v2 `NostrGroupDataExtension` parser expects — the image fields are the + * last ones it reads — so populating an avatar stays byte-compatible with + * whitenoise/mdk (no trailing bytes). The plaintext MIME type is intentionally NOT + * stored here: mdk rejects any trailing bytes at a known version and has no such field. */ @Immutable data class MarmotGroupData( @@ -89,11 +90,11 @@ data class MarmotGroupData( val relays: List = emptyList(), /** SHA-256 hash (hex) of the ENCRYPTED group image blob (= its Blossom hash). Null if no image. */ val imageHash: HexKey? = null, - /** Raw 32-byte ChaCha20-Poly1305 key for the image blob (canonical scheme). Null if no image. */ + /** 32-byte HKDF seed for the image AEAD key (MIP-01 v2). Null if no image. */ val imageKey: ByteArray? = null, /** 12-byte ChaCha20-Poly1305 nonce for image encryption. Null if no image. */ val imageNonce: ByteArray? = null, - /** Raw 32-byte secret key of the fresh Nostr keypair that authorizes Blossom writes. Null if no image. */ + /** 32-byte HKDF seed for the Blossom-auth keypair (MIP-01 v2). Null if no image. */ val imageUploadKey: ByteArray? = null, /** * Disappearing-message duration in seconds (v3+). @@ -102,12 +103,6 @@ data class MarmotGroupData( * Per MIP-01, a value of `0` MUST be rejected. */ val disappearingMessageSecs: ULong? = null, - /** - * Canonical MIME type of the plaintext image (e.g. `image/jpeg`), fed into the - * `marmot-group-image-v1` AEAD associated data. `null` for legacy groups that - * predate the field; the decryptor then falls back to the deprecated scheme. - */ - val imageMediaType: String? = null, ) { init { require(version > 0) { "MarmotGroupData version 0 is reserved/invalid" } @@ -134,14 +129,12 @@ data class MarmotGroupData( imageKey: ByteArray, imageNonce: ByteArray, imageUploadKey: ByteArray, - imageMediaType: String, ): MarmotGroupData = copy( imageHash = imageHash, imageKey = imageKey, imageNonce = imageNonce, imageUploadKey = imageUploadKey, - imageMediaType = imageMediaType, ) /** Return a copy with the group image cleared. */ @@ -151,7 +144,6 @@ data class MarmotGroupData( imageKey = null, imageNonce = null, imageUploadKey = null, - imageMediaType = null, ) /** @@ -208,13 +200,11 @@ data class MarmotGroupData( writer.putOpaqueVarInt(imageNonce ?: ByteArray(0)) writer.putOpaqueVarInt(imageUploadKey ?: ByteArray(0)) - // disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs). - // Emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps the wire - // format byte-for-byte compatible with older implementations (MDK v2). It is ALSO - // emitted (as an empty 0-byte field) whenever image_media_type follows, so the - // trailing field stays positionally unambiguous on decode. - val emitDisappearing = version >= 3 || imageMediaType != null - if (emitDisappearing) { + // v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs). + // Only emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps the + // wire format byte-for-byte compatible with mdk's v1/v2 NostrGroupDataExtension + // parser (which ends at image_upload_key and rejects any trailing bytes). + if (version >= 3) { val disappearingBytes = disappearingMessageSecs?.let { secs -> val out = ByteArray(8) @@ -228,12 +218,6 @@ data class MarmotGroupData( writer.putOpaqueVarInt(disappearingBytes) } - // Trailing image_media_type (canonical marmot-group-image-v1). Only emitted when - // set; older readers ignore trailing bytes (MIP-01 forward compatibility). - if (imageMediaType != null) { - writer.putOpaqueVarInt(imageMediaType.encodeToByteArray()) - } - return writer.toByteArray() } @@ -319,7 +303,6 @@ data class MarmotGroupData( * opaque image_nonce * opaque image_upload_key * opaque disappearing_message_secs // v3+: 0 bytes or 8-byte uint64 (reject 0) - * opaque image_media_type // trailing: canonical image MIME (empty = none) * ``` * * Unknown trailing bytes from future versions are silently ignored for @@ -388,15 +371,6 @@ data class MarmotGroupData( } } - // Trailing image_media_type (canonical marmot-group-image-v1). Absent for - // legacy groups; an empty value decodes to null. - val imageMediaType = - if (reader.hasRemaining) { - reader.readOpaqueVarInt().takeIf { it.isNotEmpty() }?.decodeToString() - } else { - null - } - MarmotGroupData( version = version, nostrGroupId = nostrGroupId, @@ -409,7 +383,6 @@ data class MarmotGroupData( imageNonce = imageNonce, imageUploadKey = imageUploadKey, disappearingMessageSecs = disappearingMessageSecs, - imageMediaType = imageMediaType, ) } catch (_: Exception) { null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageCipher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageCipher.kt index cc5d373123..29c28acd0a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageCipher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageCipher.kt @@ -25,48 +25,47 @@ import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.ciphers.NostrCipher /** - * [NostrCipher] for a Marmot group avatar, implementing the canonical - * `marmot-group-image-v1` scheme (see [MarmotGroupImageEncryption]). + * [NostrCipher] for a Marmot group avatar, implementing the MIP-01 v2 scheme (see + * [MarmotGroupImageEncryption]) — byte-for-byte interoperable with mdk/whitenoise. * * The same instance serves two paths: * - **Upload** — the file-upload pipeline calls [encrypt] over the (compressed) * image bytes; the resulting blob is stored on Blossom and addressed by - * `SHA-256(ciphertext)`. The [imageKey]/[imageNonce] are generated up front so - * the caller can persist them into the group's [MarmotGroupData]. + * `SHA-256(ciphertext)`. The [imageKey] seed / [imageNonce] are generated up front + * so the caller can persist them into the group's [MarmotGroupData]. * - **Display** — registered in the encrypted-blob HTTP cache keyed by the blob * URL, so a fetched avatar is transparently decrypted via [decryptOrNull] - * (which also opens blobs from the deprecated MIP-01 scheme). + * (v2 first, then the v1 raw-key fallback). */ class MarmotGroupImageCipher( - /** Raw 32-byte ChaCha20-Poly1305 key (canonical) or HKDF seed (legacy fallback). */ + /** 32-byte HKDF seed stored as `image_key` (the AEAD key is derived from it). */ val imageKey: ByteArray, /** 12-byte nonce. */ val imageNonce: ByteArray, - /** Canonical MIME type of the plaintext image; null only for legacy blobs on decrypt. */ - val mediaType: String?, ) : NostrCipher { - override fun name(): String = MarmotGroupImageEncryption.AAD_LABEL + override fun name(): String = "mip01-image-encryption-v2" override fun encrypt(bytesToEncrypt: ByteArray): ByteArray { - val type = requireNotNull(mediaType) { "media type is required to encrypt a group image" } - return ChaCha20Poly1305.encrypt(bytesToEncrypt, MarmotGroupImageEncryption.buildAad(type), imageNonce, imageKey) + val aeadKey = Mip01ImageCrypto.deriveImageEncryptionKey(imageKey) + return ChaCha20Poly1305.encrypt(bytesToEncrypt, EMPTY_AAD, imageNonce, aeadKey) } override fun decrypt(bytesToDecrypt: ByteArray): ByteArray = decryptOrNull(bytesToDecrypt) ?: throw IllegalStateException("Failed to decrypt Marmot group image") - override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = MarmotGroupImageEncryption.decryptAny(bytesToDecrypt, imageKey, imageNonce, mediaType) + override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = MarmotGroupImageEncryption.decryptAny(bytesToDecrypt, imageKey, imageNonce) companion object { + private val EMPTY_AAD = ByteArray(0) + /** - * Build a cipher with a freshly-generated key + nonce, ready to encrypt a new + * Build a cipher with a freshly-generated seed + nonce, ready to encrypt a new * avatar. The generated [imageKey]/[imageNonce] are exposed on the returned * instance so the caller can persist them into [MarmotGroupData]. */ - fun forNewImage(mediaType: String): MarmotGroupImageCipher = + fun forNewImage(): MarmotGroupImageCipher = MarmotGroupImageCipher( imageKey = RandomInstance.bytes(MarmotGroupImageEncryption.KEY_LENGTH), imageNonce = RandomInstance.bytes(MarmotGroupImageEncryption.NONCE_LENGTH), - mediaType = mediaType, ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageEncryption.kt index 6249a13d6f..a47974b5f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageEncryption.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageEncryption.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.marmot.mip01Groups -import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 @@ -28,62 +27,42 @@ import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256 /** - * Marmot group image (avatar) encryption. + * Marmot group image (avatar) encryption — MIP-01 v2. * - * Implements the canonical `marmot.group.blossom.image.v1` app component scheme - * (the successor to the deprecated MIP-01 image scheme). The plaintext avatar is - * encrypted with ChaCha20-Poly1305 and the ciphertext is uploaded as an opaque - * blob to a Blossom server, addressed by the SHA-256 of the *ciphertext*. + * This is byte-for-byte interoperable with the reference implementation + * (`mdk-core`'s `extension/group_image.rs`, used by whitenoise): a group avatar + * is encrypted with ChaCha20-Poly1305 and the ciphertext is uploaded to Blossom, + * addressed by the SHA-256 of the *ciphertext*. * - * The encryption parameters live inside the group's [MarmotGroupData] extension: - * - `image_key` — the raw 32-byte ChaCha20-Poly1305 key (NOT an HKDF seed). - * - `image_nonce` — the 12-byte nonce. - * - `image_hash` — SHA-256 of the encrypted blob (= the Blossom hash). - * - `image_upload_key` — the raw 32-byte secret key of a fresh Nostr keypair used - * to authorize Blossom writes (see [MarmotGroupData.imageUploadKey]). - * - `media_type` — the canonical MIME type of the plaintext image. + * The parameters live inside the group's [MarmotGroupData] extension: + * - `image_key` — a 32-byte HKDF **seed**. The AEAD key is + * `HKDF-SHA256(salt=∅, ikm=image_key, info="mip01-image-encryption-v2", 32)` + * (see [Mip01ImageCrypto.deriveImageEncryptionKey]). + * - `image_nonce` — the 12-byte ChaCha20-Poly1305 nonce (used verbatim). + * - `image_hash` — SHA-256 of the encrypted blob (= the Blossom hash). + * - `image_upload_key` — a 32-byte HKDF **seed**. The Blossom-auth secp256k1 + * secret is `HKDF-SHA256(salt=∅, ikm=image_upload_key, info="mip01-blossom-upload-v2", 32)` + * (see [Mip01ImageCrypto.deriveBlossomUploadSeed]). * - * ``` - * aad = "marmot-group-image-v1" || 0x00 || media_type - * encrypted_blob = ChaCha20-Poly1305.encrypt(image_key, image_nonce, plaintext, aad) - * image_hash = SHA-256(encrypted_blob) - * ``` + * The AEAD uses **no associated data** (empty AAD), matching MIP-01. The plaintext + * MIME type is descriptive metadata only — it is deliberately NOT bound into the + * crypto and NOT stored on the wire, because mdk's `NostrGroupDataExtension` parser + * rejects any trailing bytes at a known version and has no media_type field, so + * storing it would break group parsing for whitenoise/mdk members. * - * A fetching client MUST verify that the fetched bytes hash to `image_hash` - * before decrypting. + * A fetching client MUST verify that the fetched bytes hash to `image_hash` before + * decrypting. * - * ### Backward compatibility (parse-both) - * Amethyst never shipped the deprecated MIP-01 image scheme (no client code ever - * populated the image fields), but other clients might have. For robustness, - * [decryptAny] first tries the canonical raw-key scheme and, on authentication - * failure, falls back to the deprecated scheme where `image_key` is an HKDF seed - * ([Mip01ImageCrypto.deriveImageEncryptionKey]) and the AEAD carries no AAD. + * ### Version fallback (parse both), mirroring mdk + * [decryptAny] first tries v2 (HKDF-derived key); on failure it falls back to v1, + * where `image_key` is used directly as the AEAD key. New images are always v2. */ object MarmotGroupImageEncryption { - /** ASCII label mixed into the AEAD associated data. */ - const val AAD_LABEL = "marmot-group-image-v1" - const val KEY_LENGTH = 32 const val NONCE_LENGTH = 12 - private val NULL_SEPARATOR = byteArrayOf(0x00) private val EMPTY_AAD = ByteArray(0) - /** - * Build the AEAD associated data: `"marmot-group-image-v1" || 0x00 || media_type`. - * The media type is canonicalized the same way MIP-04 canonicalizes it - * (lowercased, trimmed, parameters stripped) so both peers derive identical bytes. - */ - fun buildAad(mediaType: String): ByteArray { - val label = AAD_LABEL.encodeToByteArray() - val mime = Mip04MediaEncryption.canonicalizeMimeType(mediaType).encodeToByteArray() - val out = ByteArray(label.size + 1 + mime.size) - label.copyInto(out, 0) - NULL_SEPARATOR.copyInto(out, label.size) - mime.copyInto(out, label.size + 1) - return out - } - /** * Result of encrypting a group image, ready to be uploaded to Blossom and * folded into a [MarmotGroupData]. @@ -91,7 +70,7 @@ object MarmotGroupImageEncryption { class Encrypted( /** The encrypted blob to upload to Blossom (ciphertext || 16-byte tag). */ val ciphertext: ByteArray, - /** Random 32-byte ChaCha20-Poly1305 key — store as `image_key`. */ + /** Random 32-byte HKDF seed — store as `image_key`. */ val imageKey: ByteArray, /** Random 12-byte nonce — store as `image_nonce`. */ val imageNonce: ByteArray, @@ -100,27 +79,25 @@ object MarmotGroupImageEncryption { ) /** - * Encrypt a plaintext image with a freshly-generated key + nonce, per the - * canonical scheme. Returns the ciphertext to upload plus the parameters to - * persist in [MarmotGroupData]. + * Encrypt a plaintext image with a freshly-generated seed + nonce (MIP-01 v2). + * Returns the ciphertext to upload plus the parameters to persist in + * [MarmotGroupData]. */ - fun encrypt( - plaintext: ByteArray, - mediaType: String, - ): Encrypted { - val imageKey = RandomInstance.bytes(KEY_LENGTH) + fun encrypt(plaintext: ByteArray): Encrypted { + val imageKeySeed = RandomInstance.bytes(KEY_LENGTH) val imageNonce = RandomInstance.bytes(NONCE_LENGTH) - val ciphertext = ChaCha20Poly1305.encrypt(plaintext, buildAad(mediaType), imageNonce, imageKey) + val aeadKey = Mip01ImageCrypto.deriveImageEncryptionKey(imageKeySeed) + val ciphertext = ChaCha20Poly1305.encrypt(plaintext, EMPTY_AAD, imageNonce, aeadKey) return Encrypted( ciphertext = ciphertext, - imageKey = imageKey, + imageKey = imageKeySeed, imageNonce = imageNonce, imageHash = sha256(ciphertext).toHexKey(), ) } /** - * Decrypt a group image blob using the canonical raw-key scheme. + * Decrypt a group image blob (MIP-01 v2): `image_key` is an HKDF seed. * * @throws IllegalStateException on authentication failure. */ @@ -128,61 +105,48 @@ object MarmotGroupImageEncryption { ciphertext: ByteArray, imageKey: ByteArray, imageNonce: ByteArray, - mediaType: String, - ): ByteArray = ChaCha20Poly1305.decrypt(ciphertext, buildAad(mediaType), imageNonce, imageKey) + ): ByteArray { + val aeadKey = Mip01ImageCrypto.deriveImageEncryptionKey(imageKey) + return ChaCha20Poly1305.decrypt(ciphertext, EMPTY_AAD, imageNonce, aeadKey) + } /** - * Decrypt a group image blob, trying the canonical raw-key scheme first and - * falling back to the deprecated MIP-01 HKDF-seed scheme. - * - * Returns null if neither scheme authenticates (wrong key, corrupt blob, or an - * unknown future scheme). - * - * @param mediaType canonical MIME type from [MarmotGroupData.imageMediaType]; - * may be null for legacy groups that predate the `media_type` field, in which - * case only the legacy fallback is attempted. + * Decrypt a group image blob, trying v2 (HKDF-derived key) first and falling + * back to v1 (raw `image_key`), exactly like mdk. Returns null if neither + * authenticates. */ fun decryptAny( ciphertext: ByteArray, imageKey: ByteArray, imageNonce: ByteArray, - mediaType: String?, ): ByteArray? { - if (imageKey.size == KEY_LENGTH && imageNonce.size == NONCE_LENGTH && mediaType != null) { - try { - return decrypt(ciphertext, imageKey, imageNonce, mediaType) - } catch (_: Exception) { - // fall through to the deprecated scheme - } - } - return decryptLegacyOrNull(ciphertext, imageKey, imageNonce) - } + if (imageKey.size != KEY_LENGTH || imageNonce.size != NONCE_LENGTH) return null - /** - * Deprecated MIP-01 image scheme: `image_key` is an HKDF seed rather than the - * raw AEAD key, and the AEAD carries no associated data. Kept only so we can - * still open avatars produced by pre-canonical clients. - */ - private fun decryptLegacyOrNull( - ciphertext: ByteArray, - imageKeySeed: ByteArray, - imageNonce: ByteArray, - ): ByteArray? = + // v2: image_key is an HKDF seed. try { - if (imageKeySeed.size != Mip01ImageCrypto.OUTPUT_LENGTH || imageNonce.size != NONCE_LENGTH) { - null - } else { - val key = Mip01ImageCrypto.deriveImageEncryptionKey(imageKeySeed) - ChaCha20Poly1305.decrypt(ciphertext, EMPTY_AAD, imageNonce, key) - } + return decrypt(ciphertext, imageKey, imageNonce) + } catch (_: Exception) { + // fall through to v1 + } + + // v1: image_key is the AEAD key directly. + return try { + ChaCha20Poly1305.decrypt(ciphertext, EMPTY_AAD, imageNonce, imageKey) } catch (_: Exception) { null } + } /** - * Generate the raw 32-byte secret key of a fresh Nostr keypair to authorize - * Blossom writes — store as [MarmotGroupData.imageUploadKey]. Any admin that - * later holds this value can re-sign uploads/deletions for the blob. + * Generate the 32-byte HKDF **seed** stored as [MarmotGroupData.imageUploadKey]. + * The actual Blossom-auth secp256k1 secret is derived from it via + * [deriveUploadKeypairSecret]. */ fun generateUploadKey(): ByteArray = RandomInstance.bytes(KEY_LENGTH) + + /** + * Derive the 32-byte secp256k1 secret used to authorize Blossom writes for the + * avatar from the stored `image_upload_key` seed (MIP-01 v2). + */ + fun deriveUploadKeypairSecret(imageUploadKey: ByteArray): ByteArray = Mip01ImageCrypto.deriveBlossomUploadSeed(imageUploadKey) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt index 448a8c4966..6af28d836a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption import com.vitorpamplona.quartz.marmot.mip01Groups.Mip01ImageCrypto +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 @@ -41,62 +42,64 @@ class MarmotGroupImageTest { private val nostrGroupId = "aa".repeat(32) private val plaintext = "PNGDATA-a-fake-avatar-image-payload".encodeToByteArray() - // ------------------------------------------------------------ encryption + private val emptyAad = ByteArray(0) + + // ------------------------------------------------------------ encryption (MIP-01 v2) @Test fun encrypt_thenDecrypt_roundTrips() { - val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/png") + val enc = MarmotGroupImageEncryption.encrypt(plaintext) assertEquals(MarmotGroupImageEncryption.KEY_LENGTH, enc.imageKey.size) assertEquals(MarmotGroupImageEncryption.NONCE_LENGTH, enc.imageNonce.size) - val decrypted = - MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/png") + val decrypted = MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce) assertContentEquals(plaintext, decrypted) } @Test fun imageHash_isSha256OfCiphertext() { - val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/jpeg") + val enc = MarmotGroupImageEncryption.encrypt(plaintext) assertEquals(sha256(enc.ciphertext).toHexKey(), enc.imageHash) } + /** + * Byte-for-byte interop guard: the AEAD key MUST be + * HKDF(image_key, "mip01-image-encryption-v2") with empty AAD — exactly what mdk's + * group_image.rs does. If this drifts, Amethyst and whitenoise stop interoperating. + */ @Test - fun mediaType_isCanonicalizedInAad() { - // "IMAGE/PNG; charset=binary" canonicalizes to "image/png" — must still decrypt with "image/png". - val enc = MarmotGroupImageEncryption.encrypt(plaintext, "IMAGE/PNG; charset=binary") - val decrypted = - MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/png") - assertContentEquals(plaintext, decrypted) + fun scheme_matchesMdk_hkdfSeedAndEmptyAad() { + val enc = MarmotGroupImageEncryption.encrypt(plaintext) + val derivedKey = Mip01ImageCrypto.deriveImageEncryptionKey(enc.imageKey) + val manual = ChaCha20Poly1305.decrypt(enc.ciphertext, emptyAad, enc.imageNonce, derivedKey) + assertContentEquals(plaintext, manual) } @Test - fun decrypt_wrongMediaType_fails() { - val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/png") + fun decrypt_wrongSeed_fails() { + val enc = MarmotGroupImageEncryption.encrypt(plaintext) assertFailsWith { - MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/jpeg") + MarmotGroupImageEncryption.decrypt(enc.ciphertext, RandomInstance.bytes(32), enc.imageNonce) } } @Test - fun decryptAny_canonical_succeeds() { - val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/webp") - val out = - MarmotGroupImageEncryption.decryptAny(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/webp") + fun decryptAny_v2_succeeds() { + val enc = MarmotGroupImageEncryption.encrypt(plaintext) + val out = MarmotGroupImageEncryption.decryptAny(enc.ciphertext, enc.imageKey, enc.imageNonce) assertNotNull(out) assertContentEquals(plaintext, out) } @Test - fun decryptAny_fallsBackToDeprecatedHkdfScheme() { - // Produce a blob with the DEPRECATED scheme: image_key is an HKDF seed, no AAD. - val seed = RandomInstance.bytes(32) + fun decryptAny_fallsBackToV1RawKey() { + // v1: image_key is used directly as the AEAD key (no HKDF), empty AAD — mdk's fallback. + val rawKey = RandomInstance.bytes(32) val nonce = RandomInstance.bytes(12) - val legacyKey = Mip01ImageCrypto.deriveImageEncryptionKey(seed) - val legacyBlob = ChaCha20Poly1305.encrypt(plaintext, ByteArray(0), nonce, legacyKey) + val v1Blob = ChaCha20Poly1305.encrypt(plaintext, emptyAad, nonce, rawKey) - // decryptAny tries canonical first (seed-as-raw-key + media AAD → fails), then legacy. - val out = MarmotGroupImageEncryption.decryptAny(legacyBlob, seed, nonce, "image/png") + val out = MarmotGroupImageEncryption.decryptAny(v1Blob, rawKey, nonce) assertNotNull(out) assertContentEquals(plaintext, out) } @@ -108,35 +111,42 @@ class MarmotGroupImageTest { RandomInstance.bytes(64), RandomInstance.bytes(32), RandomInstance.bytes(12), - "image/png", ) assertNull(out) } + @Test + fun uploadKeypairSecret_isDeterministicAndDistinctFromSeed() { + val seed = RandomInstance.bytes(32) + val s1 = MarmotGroupImageEncryption.deriveUploadKeypairSecret(seed) + val s2 = MarmotGroupImageEncryption.deriveUploadKeypairSecret(seed) + assertEquals(32, s1.size) + assertContentEquals(s1, s2) + assertTrue(!s1.contentEquals(seed), "upload secret must be derived, not the raw seed") + } + @Test fun cipher_encryptDecrypt_roundTrips_asUsedByUploadAndDisplay() { - // The upload path builds a fresh cipher, encrypts, and stores its key/nonce; - // the display path rebuilds the same cipher from those fields and decrypts. - val uploadCipher = MarmotGroupImageCipher.forNewImage("image/png") + val uploadCipher = MarmotGroupImageCipher.forNewImage() val blob = uploadCipher.encrypt(plaintext) - val displayCipher = MarmotGroupImageCipher(uploadCipher.imageKey, uploadCipher.imageNonce, "image/png") + val displayCipher = MarmotGroupImageCipher(uploadCipher.imageKey, uploadCipher.imageNonce) assertContentEquals(plaintext, displayCipher.decrypt(blob)) assertContentEquals(plaintext, displayCipher.decryptOrNull(blob)) } @Test - fun cipher_decryptOrNull_wrongKey_returnsNull() { - val uploadCipher = MarmotGroupImageCipher.forNewImage("image/png") + fun cipher_decryptOrNull_wrongSeed_returnsNull() { + val uploadCipher = MarmotGroupImageCipher.forNewImage() val blob = uploadCipher.encrypt(plaintext) - val wrong = MarmotGroupImageCipher(RandomInstance.bytes(32), uploadCipher.imageNonce, "image/png") + val wrong = MarmotGroupImageCipher(RandomInstance.bytes(32), uploadCipher.imageNonce) assertNull(wrong.decryptOrNull(blob)) } // ------------------------------------------------------------ wire format @Test - fun wire_roundTrips_withImageAndMediaType() { + fun wire_roundTrips_withImage() { val original = MarmotGroupData( version = 2, @@ -149,17 +159,14 @@ class MarmotGroupImageTest { imageKey = "dd".repeat(32).hexToByteArray(), imageNonce = "ee".repeat(12).hexToByteArray(), imageUploadKey = "ff".repeat(32).hexToByteArray(), - imageMediaType = "image/png", ) val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls())) assertEquals("Otters", decoded.name) - assertEquals("river friends", decoded.description) assertEquals("cc".repeat(32), decoded.imageHash) assertContentEquals("dd".repeat(32).hexToByteArray(), decoded.imageKey) assertContentEquals("ee".repeat(12).hexToByteArray(), decoded.imageNonce) assertContentEquals("ff".repeat(32).hexToByteArray(), decoded.imageUploadKey) - assertEquals("image/png", decoded.imageMediaType) assertNull(decoded.disappearingMessageSecs) assertTrue(decoded.hasImage()) } @@ -176,11 +183,51 @@ class MarmotGroupImageTest { ) val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls())) assertEquals("Plain", decoded.name) - assertNull(decoded.imageMediaType) assertNull(decoded.imageHash) assertTrue(!decoded.hasImage()) } + /** + * INTEROP GUARD: at version 2, a group WITH an image must serialize with the image + * fields as the LAST fields and ZERO trailing bytes — exactly what mdk-core's + * `TlsNostrGroupDataExtensionV1V2` parser consumes. mdk rejects any trailing bytes at a + * known version, so a stray byte here silently breaks the group for whitenoise/mdk + * members. This test reproduces mdk's v1/v2 field consumption and asserts nothing is + * left over. + */ + @Test + fun wire_v2WithImage_hasNoTrailingBytesForMdk() { + val withImage = + MarmotGroupData( + version = 2, + nostrGroupId = nostrGroupId, + name = "Compat", + description = "d", + adminPubkeys = listOf("bb".repeat(32)), + relays = listOf("wss://relay.example/"), + imageHash = "cc".repeat(32), + imageKey = "dd".repeat(32).hexToByteArray(), + imageNonce = "ee".repeat(12).hexToByteArray(), + imageUploadKey = "ff".repeat(32).hexToByteArray(), + ) + + val reader = TlsReader(withImage.encodeTls()) + reader.readUint16() // version + reader.readBytes(32) // nostr_group_id + reader.readOpaqueVarInt() // name + reader.readOpaqueVarInt() // description + reader.readOpaqueVarInt() // admin_pubkeys + reader.readOpaqueVarInt() // relays + reader.readOpaqueVarInt() // image_hash + reader.readOpaqueVarInt() // image_key + reader.readOpaqueVarInt() // image_nonce + reader.readOpaqueVarInt() // image_upload_key + assertTrue( + !reader.hasRemaining, + "v2 image extension has trailing bytes past image_upload_key → mdk would reject it", + ) + } + @Test fun wire_roundTrips_v3Disappearing_withImage() { val original = @@ -194,38 +241,13 @@ class MarmotGroupImageTest { imageKey = "dd".repeat(32).hexToByteArray(), imageNonce = "ee".repeat(12).hexToByteArray(), imageUploadKey = "ff".repeat(32).hexToByteArray(), - imageMediaType = "image/jpeg", disappearingMessageSecs = 3600UL, ) val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls())) assertEquals(3600UL, decoded.disappearingMessageSecs) - assertEquals("image/jpeg", decoded.imageMediaType) assertTrue(decoded.hasImage()) } - @Test - fun wire_v2WithMediaType_readByLegacyDecoderIgnoresTrailing() { - // Emitting media_type at v2 forces an empty disappearing field to keep alignment. - // A reader that stops at disappearing (older logic) must still parse cleanly and - // see no disappearing timer. - val withImage = - MarmotGroupData( - version = 2, - nostrGroupId = nostrGroupId, - name = "Compat", - adminPubkeys = listOf("bb".repeat(32)), - relays = emptyList(), - imageHash = "cc".repeat(32), - imageKey = "dd".repeat(32).hexToByteArray(), - imageNonce = "ee".repeat(12).hexToByteArray(), - imageUploadKey = "ff".repeat(32).hexToByteArray(), - imageMediaType = "image/png", - ) - val decoded = assertNotNull(MarmotGroupData.decodeTls(withImage.encodeTls())) - assertNull(decoded.disappearingMessageSecs) - assertEquals("image/png", decoded.imageMediaType) - } - @Test fun withImage_andWithoutImage_helpers() { val base = @@ -234,15 +256,12 @@ class MarmotGroupImageTest { name = "Base", adminPubkeys = listOf("bb".repeat(32)), ) - val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/png") - val withImg = - base.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, RandomInstance.bytes(32), "image/png") + val enc = MarmotGroupImageEncryption.encrypt(plaintext) + val withImg = base.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, RandomInstance.bytes(32)) assertTrue(withImg.hasImage()) - assertEquals("image/png", withImg.imageMediaType) val cleared = withImg.withoutImage() assertTrue(!cleared.hasImage()) - assertNull(cleared.imageMediaType) assertNull(cleared.imageUploadKey) } }