fix(marmot): match mdk/whitenoise MIP-01 v2 group-image scheme for interop

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
This commit is contained in:
Claude
2026-07-15 00:32:32 +00:00
parent 5753da7be3
commit de2e71dad0
9 changed files with 202 additions and 254 deletions
@@ -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)
@@ -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
}
@@ -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,
)
}
@@ -749,7 +749,6 @@ class MarmotManager(
hash = metadata.imageHash!!,
key = metadata.imageKey!!,
nonce = metadata.imageNonce!!,
mediaType = metadata.imageMediaType,
)
} else {
null
@@ -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
}
}
@@ -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<String> = 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<V>
* opaque image_upload_key<V>
* opaque disappearing_message_secs<V> // v3+: 0 bytes or 8-byte uint64 (reject 0)
* opaque image_media_type<V> // 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
@@ -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,
)
}
}
@@ -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)
}
@@ -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<IllegalStateException> {
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)
}
}